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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-18 11:45:13 +05:30
parent dccb1beda5
commit 30d10921c2
45 changed files with 53 additions and 3517 deletions

83
.env
View File

@@ -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

View File

@@ -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=

View File

@@ -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<string | null>(null);
const conversion = useConversionReport({bucket: 'day'});
const sales = useSales();
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}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
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}
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}
onClick={() => 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}
<FeatureUnavailable
title="Products, payments and refunds"
description="Individual sales are listed above. What is still not recorded anywhere is the product catalogue and stock, how customers paid, and refunds — so those sections stay empty rather than being filled with sample data."
/>
</VStack>
);
}

View File

@@ -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<FloorVisit | null>(null);
const [selling, setSelling] = useState<FloorVisit | null>(null);
return (
<VStack gap={5}>
<PageHeader
eyebrow="Live"
title="Floor"
description={`Customers in ${scopeLabel} right now.`}
controls={<ScopeControls />}
/>
{/* The platform's own refusal, shown verbatim — it names who holds the
customer, which is the part staff need. */}
{conflict ? <Banner status="warning" title={conflict.message} /> : null}
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={3} height={190} />}
empty={
<EmptyPanel
icon="visitors"
title="Nobody on the floor"
description="Customers appear here the moment a camera sees them."
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 300, repeat: 'fit'}} gap={4}>
{rows.map((v) => {
const unknown = v.visitorId === null;
const heldByOther = v.attendedBy !== null && !v.attendedByMe;
return (
<Card key={v.visitId}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{v.label ?? 'Unrecognised customer'}
</Heading>
{v.customerRef ? (
<Text size="xsm" color="secondary" className="font-mono">
{v.customerRef}
</Text>
) : null}
</HStack>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={v.status === 'attending' ? 'warning' : 'success'}
label={v.status}
/>
<Text size="xsm" color="secondary">
{v.status === 'attending' && v.attendedByName
? `With ${v.attendedByName}`
: 'Waiting'}
{' · '}
{whenSeen(v.detectedAt)}
</Text>
</HStack>
{/* Real profile data only. An unrecognised arrival says so
and offers the form; it never shows a placeholder name. */}
{unknown ? (
<Text size="sm" color="secondary">
The cameras have not seen this person before.
</Text>
) : (
<Text size="sm" color="secondary">
{v.previousVisits === 0
? 'First visit'
: `${v.previousVisits} previous ${
v.previousVisits === 1 ? 'visit' : 'visits'
}`}
{v.phone ? ` · ${v.phone}` : ''}
</Text>
)}
<HStack gap={2}>
{v.attendedByMe ? (
<>
<Button
variant="secondary"
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'release')}
label="Release"
/>
<Button
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'complete')}
label="Complete"
/>
</>
) : (
<Button
// Not hidden when somebody else holds them: pressing
// it returns the platform's own refusal naming who,
// which is more useful than a control that vanishes.
variant={heldByOther ? 'secondary' : 'primary'}
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'attend')}
label={heldByOther ? 'Taken' : 'Take'}
/>
)}
{/* Sale entry is offered only to whoever holds the
customer — recording a sale against somebody else's
customer would attribute it to the wrong person. */}
{v.attendedByMe ? (
<Button
variant="ghost"
onClick={() => setSelling(v)}
label="Record sale"
/>
) : null}
{unknown ? (
<Button
variant="ghost"
onClick={() => setNaming(v)}
label="Add customer"
/>
) : null}
</HStack>
</VStack>
</Card>
);
})}
</Grid>
)}
</AsyncBoundary>
{/* The visit is NOT completed automatically after a sale. LOYALY.md §9
says a visit is completed when the merchant finishes the interaction,
which is not the same moment as recording a sale — a customer often
buys and then keeps browsing. Completing here would clear them off
the floor while they are still standing in the shop. */}
{selling ? (
<SaleEntryDialog
visit={selling}
onClose={() => setSelling(null)}
onSaved={() => {
setSelling(null);
resource.refetch();
}}
/>
) : null}
{naming ? (
<NameCustomerDialog
visit={naming}
onClose={() => setNaming(null)}
onSaved={() => {
setNaming(null);
resource.refetch();
}}
/>
) : null}
</VStack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
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 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,
(token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}),
(page) => (page.items ?? []).map(toFloorVisit),
);
}

View File

@@ -1,33 +0,0 @@
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'}},
);
}
}

View File

@@ -1,136 +0,0 @@
import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
import {toSiteParam} from '@/services/api/range';
import {serveUpstream, failureFrom} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession';
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,
(token, query) =>
salesApi.list(token, {
site: toSiteParam(query.storeId),
limit: 50,
}),
(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) {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -1,37 +0,0 @@
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';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/attend
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.attend(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -1,37 +0,0 @@
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';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/complete
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.complete(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -1,37 +0,0 @@
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';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/release
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.release(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -42,11 +42,7 @@ import type {UserRole} from '@/features/auth/types/auth';
* `admin`.
*/
export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
/**
* The shop floor: who is in the shop now, and who is serving them. The only
* surface in this console aimed at somebody standing behind a counter.
*/
staff: '/floor',
staff: DEFAULT_DESTINATION,
manager: DEFAULT_DESTINATION,
owner: DEFAULT_DESTINATION,

View File

@@ -1,127 +0,0 @@
'use client';
import {useEffect, useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Badge} from '@astryxdesign/core/Badge';
import {formatPaise} from '@/features/commerce/services/money';
import type {Sale} from '@/features/commerce/types/sale';
/**
* One sale, with its lines. LOYALY.md §24.
*
* Renders only fields the API actually returns. Where the platform has nothing
* — no invoice number, no named customer — the row says so rather than
* inventing a placeholder, because a fabricated invoice number on a screen
* somebody reconciles against is worse than a visible gap.
*/
export function SaleDetailDialog({
saleId,
onClose,
}: {
saleId: string;
onClose: () => void;
}) {
const [sale, setSale] = useState<Sale | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/sales/${encodeURIComponent(saleId)}`);
const body = await res.json().catch(() => ({}));
if (cancelled) return;
if (!res.ok) {
setError(body?.error?.message ?? 'Could not load this sale.');
return;
}
setSale(body.data as Sale);
} catch {
if (!cancelled) setError('Could not reach the platform.');
}
})();
// A dialog closed mid-request must not write into an unmounted component.
return () => {
cancelled = true;
};
}, [saleId]);
const purchased = sale?.lines.filter((l) => l.intent === 'purchased') ?? [];
const enquiries = sale?.lines.filter((l) => l.intent === 'enquired') ?? [];
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={520}
aria-label="Sale detail"
>
<VStack gap={4} width="100%">
<DialogHeader
title={sale?.invoiceNo ?? 'Sale'}
subtitle={sale ? new Date(sale.at).toLocaleString() : undefined}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{error ? <Banner status="error" title={error} /> : null}
{!sale && !error ? <Text size="sm" color="secondary">Loading…</Text> : null}
{sale ? (
<VStack gap={4}>
<VStack gap={1}>
<Text size="sm" color="secondary">
Customer: {sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
</Text>
<Text size="sm" color="secondary">
Served by: {sale.staffName ?? '—'}
</Text>
<Text size="sm" color="secondary">
Store: {sale.siteId}
</Text>
</VStack>
{purchased.length > 0 ? (
<VStack gap={2}>
<Heading level={4}>Purchased</Heading>
{purchased.map((l, i) => (
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
<Text size="sm">{l.productName}</Text>
<Text size="sm">{formatPaise(l.billablePaise)}</Text>
</HStack>
))}
</VStack>
) : null}
{enquiries.length > 0 ? (
<VStack gap={2}>
<HStack gap={2} vAlign="center">
<Heading level={4}>Enquiries</Heading>
<Badge label="not billed" />
</HStack>
{enquiries.map((l, i) => (
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
<Text size="sm" color="secondary">{l.productName}</Text>
<Text size="sm" color="disabled">
{l.pricePaise > 0
? formatPaise(l.pricePaise)
: 'no price'}
</Text>
</HStack>
))}
</VStack>
) : null}
<HStack gap={2} hAlign="between" vAlign="center">
<Text size="sm" color="secondary">Total</Text>
<Heading level={3}>{formatPaise(sale.totalPaise)}</Heading>
</HStack>
</VStack>
) : null}
</VStack>
</Dialog>
);
}

View File

@@ -1,329 +0,0 @@
'use client';
import {useMemo, useRef, useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Badge} from '@astryxdesign/core/Badge';
import {Card} from '@astryxdesign/core/Card';
import {
billablePaise,
formatPaise,
parseRupeesToPaise,
} from '@/features/commerce/services/money';
import type {FloorVisit} from '@/features/floor/types/floor';
/**
* Sale entry for one customer on the floor. LOYALY.md §10–§13, §17.
*
* ── The distinction this screen exists to make ───────────────────────────
* Every line is PURCHASED or ENQUIRED, and an enquiry never reaches the bill.
* §11 calls that the important rule, so the two are shown in separate blocks
* rather than hidden behind a dropdown: a merchant must see at a glance what
* they are charging for.
*
* ── There is no product catalogue, and that is correct ───────────────────
* §11 records `product_name` and `price`; §33-E leaves `product_id` optional
* and unresolved. Free text is the specified behaviour, not a placeholder for
* a picker — so this form neither invents a catalogue nor claims one is
* coming.
*
* Quantity is deliberately absent. The spec's line shape is name, price,
* intent; a quantity field would be a business rule nobody wrote and a value
* the backend cannot store.
*/
interface DraftLine {
key: string;
productName: string;
/** Integer paise, parsed once on entry. Never a float. */
pricePaise: number;
intent: 'purchased' | 'enquired';
}
/**
* A per-sale key. `crypto.randomUUID` everywhere modern; the timestamp branch
* is only for a non-secure context, where `crypto` may be absent entirely.
*/
function newIdempotencyKey(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `draft-${Date.now()}`;
}
export function SaleEntryDialog({
visit,
onClose,
onSaved,
}: {
visit: FloorVisit;
onClose: () => void;
onSaved: (saleId: string) => void;
}) {
/**
* §13: minted when the DRAFT OPENS, not when Confirm is pressed.
*
* That is the whole mechanism. A double tap, a timeout retry and an app
* restart all carry this same key, so the platform collapses them into one
* sale. A key generated at submit time would be unique per attempt and every
* retry would create another sale — which is the failure the key exists to
* prevent.
*
* A ref rather than state: it must survive every re-render unchanged.
*/
const idempotencyKey = useRef<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [name, setName] = useState('');
const [price, setPrice] = useState('');
const [invoiceNo, setInvoiceNo] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [done, setDone] = useState<{saleId: string; total: string} | null>(null);
const total = useMemo(() => billablePaise(lines), [lines]);
const purchased = lines.filter((l) => l.intent === 'purchased');
const enquiries = lines.filter((l) => l.intent === 'enquired');
function addLine(intent: 'purchased' | 'enquired') {
setError(null);
const productName = name.trim();
if (productName === '') {
setError('Give the item a name.');
return;
}
const paise = price.trim() === '' ? 0 : parseRupeesToPaise(price);
if (paise === null) {
setError('That price is not a valid amount.');
return;
}
// §17, mirrored here for UX only. The server enforces it too, and its
// answer is the one that decides — this just saves a round trip.
if (intent === 'purchased' && paise <= 0) {
setError('A purchased item needs a price.');
return;
}
setLines((prev) => [
...prev,
{
// Index-free and content-based, so removing a line cannot make two
// remaining rows collide on a key.
key: `${Date.now()}-${prev.length}-${productName}`,
productName,
pricePaise: paise,
intent,
},
]);
setName('');
setPrice('');
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key));
}
async function confirm() {
setSaving(true);
setError(null);
try {
/**
* Minted here, on the first attempt, and kept in the ref for every one
* after it.
*
* It used to be generated in `useRef(...)`, whose argument React
* evaluates on EVERY render — so `crypto.randomUUID()` and `Date.now()`
* ran on each keystroke in this dialog, and the lint rule that caught it
* is right: `Date.now()` during render is impure and its result is
* discarded anyway. An event handler is the correct place for both.
*
* The retry guarantee is unchanged, which is the part that matters: the
* ref is only filled once, so a double tap, a timeout retry and a
* resubmit all send the SAME key and the platform collapses them into
* one sale. A fresh dialog is a fresh component, so the next sale gets a
* fresh key.
*/
idempotencyKey.current ??= newIdempotencyKey();
const res = await fetch('/api/sales', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({
idempotencyKey: idempotencyKey.current,
// Taken from the floor context, never typed. §4: do not ask for a
// visit id the flow already knows. Staff identity is not sent at
// all — the platform derives it from the session.
visitId: visit.visitId,
visitorId: visit.visitorId ?? undefined,
invoiceNo: invoiceNo.trim(),
site: visit.siteId,
lines: lines.map((l) => ({
productName: l.productName,
pricePaise: l.pricePaise,
intent: l.intent,
})),
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setError(body?.error?.message ?? 'Could not record this sale.');
return;
}
// The figure shown now is the SERVER's, computed by the database from
// the purchased lines. The running total above is only what the merchant
// watched while typing.
const sale = body?.data?.sale;
setDone({
saleId: body?.data?.saleId ?? '',
total:
typeof sale?.totalPaise === 'number'
? formatPaise(sale.totalPaise)
: formatPaise(total),
});
} catch {
setError('Could not reach the platform. The sale was not recorded.');
} finally {
setSaving(false);
}
}
const customerName = visit.label ?? 'Unrecognised customer';
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={560}
aria-label="Record a sale"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Record a sale"
subtitle={`${customerName}${visit.customerRef ? ` · ${visit.customerRef}` : ''}`}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{done ? (
<VStack gap={4}>
<Banner status="info" title={`Sale recorded — ${done.total}`} />
<Text size="sm" color="secondary">
The customer is still on the floor. Complete their visit when you
have finished with them.
</Text>
<HStack gap={2} hAlign="end">
<Button onClick={() => onSaved(done.saleId)} label="Done" />
</HStack>
</VStack>
) : (
<>
<HStack gap={2} vAlign="end">
<TextInput
label="Item"
value={name}
onChange={setName}
placeholder="What did they look at?"
/>
<TextInput
label="Price"
value={price}
onChange={setPrice}
placeholder="0.00"
/>
</HStack>
<HStack gap={2}>
<Button
variant="secondary"
onClick={() => addLine('enquired')}
label="Add enquiry"
/>
<Button onClick={() => addLine('purchased')} label="Add purchase" />
</HStack>
{error ? <Banner status="error" title={error} /> : null}
{purchased.length > 0 ? (
<Card>
<VStack gap={2}>
<Heading level={4}>Purchased</Heading>
{purchased.map((l) => (
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
<Text size="sm">{l.productName}</Text>
<HStack gap={2} vAlign="center">
<Text size="sm">{formatPaise(l.pricePaise)}</Text>
<Button
variant="ghost"
onClick={() => removeLine(l.key)}
label="Remove"
/>
</HStack>
</HStack>
))}
</VStack>
</Card>
) : null}
{enquiries.length > 0 ? (
<Card>
<VStack gap={2}>
<HStack gap={2} vAlign="center">
<Heading level={4}>Enquiries</Heading>
<Badge label="not billed" />
</HStack>
{enquiries.map((l) => (
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
<Text size="sm" color="secondary">
{l.productName}
</Text>
<HStack gap={2} vAlign="center">
{/* A quoted price is kept for the record and shown in
a muted tone: §11 allows an enquiry to carry one and
requires that it never increase the bill. */}
<Text size="sm" color="disabled">
{l.pricePaise > 0 ? formatPaise(l.pricePaise) : 'no price'}
</Text>
<Button
variant="ghost"
onClick={() => removeLine(l.key)}
label="Remove"
/>
</HStack>
</HStack>
))}
</VStack>
</Card>
) : null}
<TextInput
label="Invoice number (optional)"
value={invoiceNo}
onChange={setInvoiceNo}
placeholder="INV-…"
/>
<HStack gap={2} hAlign="between" vAlign="center">
<Text size="sm" color="secondary">
{purchased.length} purchased · {enquiries.length} enquiries
</Text>
<Heading level={3}>{formatPaise(total)}</Heading>
</HStack>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
// Disabled in flight so a double tap cannot fire twice. The
// idempotency key is the real defence; this is the part the
// user can see.
isDisabled={saving || lines.length === 0}
onClick={() => void confirm()}
label={saving ? 'Recording…' : 'Confirm sale'}
/>
</HStack>
</>
)}
</VStack>
</Dialog>
);
}

View File

@@ -1,11 +0,0 @@
'use client';
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
import type {Resource} from '@/shared/hooks/useResource';
import type {Sale} from '@/features/commerce/types/sale';
export function useSales(): Resource<Sale[]> {
return useResource(saleRepository.list(useScope()));
}

View File

@@ -1,13 +0,0 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {Sale} from '@/features/commerce/types/sale';
/**
* Sales, addressed by the platform's own resource name.
*
* Scoped like every other read, so the store switcher and the range picker
* change what this returns without the Sales screen knowing how.
*/
export const saleRepository = {
list: (scope: Scope): Endpoint<Sale[]> => scopedEndpoint('/api/sales', scope, {}),
};

View File

@@ -1,57 +0,0 @@
/**
* Rupees ↔ paise, in one place.
*
* ── Why the form holds PAISE, not rupees ─────────────────────────────────
* LOYALY.md §10: money is integer minor units and never a float. If the sale
* form kept rupees it would add 18.1 + 240.05 in binary floating point and the
* running total a merchant reads would drift from the one the server computes.
* So a price is parsed to an integer ONCE, on entry, and every sum after that
* is integer arithmetic.
*
* The backend stays authoritative regardless — `sales.total_paise` is written
* by a database trigger from the purchased lines, and nothing the client sends
* can set it. What this file protects is the number shown to the person typing.
*/
/**
* "18", "18.5", "₹1,250.00" → paise. Returns null for anything that is not a
* non-negative amount, so the caller can refuse rather than submit a NaN.
*
* Parsed by SPLITTING ON THE DECIMAL POINT rather than `Math.round(x * 100)`:
* 19.99 * 100 is 1998.9999999999998 in IEEE 754, and rounding hides that only
* until it does not.
*/
export function parseRupeesToPaise(input: string): number | null {
const clean = input.replace(/[₹,\s]/g, '').trim();
if (clean === '') return null;
if (!/^\d+(\.\d{0,2})?$/.test(clean)) return null;
const [whole, frac = ''] = clean.split('.');
const paise = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
return Number.isSafeInteger(paise) ? paise : null;
}
/** Paise → "₹1,250.00" for display. Integer division, never a float sum. */
export function formatPaise(paise: number): string {
const sign = paise < 0 ? '-' : '';
const abs = Math.abs(Math.trunc(paise));
const rupees = Math.trunc(abs / 100);
const rest = abs % 100;
return `${sign}₹${rupees.toLocaleString('en-IN')}.${String(rest).padStart(2, '0')}`;
}
/**
* The bill: purchased lines only.
*
* This mirrors the database trigger deliberately and is NOT the source of
* truth — the value shown after submission comes back from the server. It
* exists so the running total a merchant watches while typing matches the one
* they will be charged, and the rule is stated once here rather than in each
* component that renders a subtotal.
*/
export function billablePaise(lines: {pricePaise: number; intent: string}[]): number {
return lines.reduce(
(sum, l) => (l.intent === 'purchased' ? sum + l.pricePaise : sum),
0,
);
}

View File

@@ -1,72 +0,0 @@
export interface CommerceFilterState {
dateRange: 'today' | '7d' | '30d' | '90d' | 'custom';
storeId: string;
category: string;
paymentMethod: string;
salesChannel: string;
}
export interface MetricCardData {
title: string;
value: string;
change: string;
trendDirection: 'up' | 'down' | 'neutral';
subtitle?: string;
}
export interface FunnelStage {
stage: string;
count: number;
label: string;
conversionRate: string;
color: string;
}
export interface PaymentBreakdownItem {
method: string;
amount: string;
percentage: number;
color: string;
}
export interface TopProductRow {
id: string;
name: string;
category: string;
unitsSold: number;
revenue: string;
growth: string;
growthDirection: 'up' | 'down';
stockCount: number;
stockStatus: 'in_stock' | 'low_stock' | 'out_of_stock';
}
export interface HeatmapCell {
day: string;
hour: string;
value: number; // intensity 0 - 100
}
export interface ForecastDataPoint {
date: string;
actual?: number;
forecast: number;
lowerBound?: number;
upperBound?: number;
}
export interface BusinessAlert {
id: string;
type: 'low_stock' | 'revenue_drop' | 'refund_spike' | 'best_seller' | 'promotion_opportunity';
title: string;
description: string;
actionText: string;
severity: 'critical' | 'warning' | 'info' | 'success';
}
export interface OperationalFlowStep {
step: string;
label: string;
detail: string;
iconName: string;
}

View File

@@ -1,35 +0,0 @@
/**
* A sale, as the console consumes it.
*
* Money stays INTEGER PAISE all the way to the pixel. The first version
* converted to rupees in the BFF and every component converted back with
* `Math.round(x * 100)` to format it — a float round-trip on every render, in
* several places, which is precisely what LOYALY.md §10 forbids.
*
* Now nothing converts. `formatPaise` turns an integer into "₹1,899.50" for
* display and that is the only place money changes shape.
*/
export interface SaleLine {
productName: string;
pricePaise: number;
intent: 'purchased' | 'enquired';
/** What this line put on the bill. Zero for every enquiry, always. */
billablePaise: number;
}
export interface Sale {
id: string;
invoiceNo: string | null;
siteId: string;
customerRef: string | null;
customerLabel: string | null;
staffName: string | null;
totalPaise: number;
currency: string;
status: string;
at: string;
/** Counts for the list. `lines` is populated only by the detail view. */
purchasedLines: number;
enquiryLines: number;
lines: SaleLine[];
}

View File

@@ -1,107 +0,0 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import type {FloorVisit} from '@/features/floor/types/floor';
/**
* Naming somebody the cameras could not identify. LOYALY.md §18.
*
* The visit id travels with the request: that is what makes this the
* first-visit flow rather than a directory entry, and it is what stops the
* face on the floor staying anonymous.
*
* The platform requires a name OR a phone — a record with neither is not a
* customer, and without that rule Save mints a blank "Visitor N" every time
* somebody taps it.
*/
export function NameCustomerDialog({
visit,
onClose,
onSaved,
}: {
visit: FloorVisit;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const canSave = name.trim() !== '' || phone.trim() !== '';
async function save() {
setSaving(true);
setError(null);
try {
const res = await fetch('/api/customers', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({name, phone, visitId: visit.visitId}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// The platform's own wording — including the 409 that says this
// arrival was identified while the form was open.
setError(body?.error?.message ?? 'Could not save this customer.');
return;
}
onSaved();
} catch {
setError('Could not reach the platform. Try again.');
} finally {
setSaving(false);
}
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={420}
aria-label="Add customer"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Add customer"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<Text size="sm" color="secondary">
This person was seen just now and does not match anyone on record.
Their details will be linked to this arrival.
</Text>
<TextInput
label="Name"
value={name}
onChange={setName}
placeholder="Full name"
/>
<TextInput
label="Phone"
value={phone}
onChange={setPhone}
placeholder="+91…"
/>
{error ? <Banner status="error" title={error} /> : null}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
isDisabled={!canSave || saving}
onClick={() => void save()}
label={saving ? 'Saving…' : 'Save customer'}
/>
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import {useCallback, useState} from 'react';
import {floorRepository} from '@/features/floor/repositories/floorRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
export type FloorAction = 'attend' | 'release' | 'complete';
/**
* The floor, plus the three lifecycle calls.
*
* ── Why a conflict re-reads instead of patching local state ──────────────
* Only the platform knows who actually won a race for a customer. When a Take
* comes back 409 the screen must show the CURRENT truth — which staff member
* holds them — and that is a fact this browser does not have. So every action,
* success or conflict, is followed by a refetch. Nothing about ownership is
* simulated here.
*/
export function useFloor() {
const resource = useResource(floorRepository.list(useScope()));
const [pending, setPending] = useState<string | null>(null);
const [conflict, setConflict] = useState<{visitId: string; message: string} | null>(null);
const act = useCallback(
async (visitId: string, action: FloorAction) => {
setPending(visitId);
setConflict(null);
try {
const res = await fetch(`/api/visits/${encodeURIComponent(visitId)}/${action}`, {
method: 'POST',
headers: {'content-type': 'application/json'},
});
if (res.status === 409) {
const body = await res.json().catch(() => ({}));
setConflict({
visitId,
// The platform's own wording names who holds the customer.
message: body?.error?.message ?? 'Somebody else is already serving this customer.',
});
}
} finally {
setPending(null);
// Refetch on every path, including the conflict: the row the browser
// is holding is now known to be stale.
resource.refetch();
}
},
[resource],
);
return {resource, act, pending, conflict, dismissConflict: () => setConflict(null)};
}

View File

@@ -1,9 +0,0 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {FloorVisit} from '@/features/floor/types/floor';
/** The floor, scoped by the workspace's selected store. */
export const floorRepository = {
list: (scope: Scope): Endpoint<FloorVisit[]> =>
scopedEndpoint('/api/floor/visits', scope, {}),
};

View File

@@ -1,24 +0,0 @@
/**
* The shop floor, as the console consumes it.
*
* `visitorId === null` means the cameras saw somebody they could not identify.
* That is not missing data — it is the state the add-customer flow exists for,
* and it must stay distinguishable from a known customer with no name yet.
*/
export interface FloorVisit {
visitId: string;
siteId: string;
detectedAt: string;
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
visitorId: string | null;
customerRef: string | null;
label: string | null;
phone: string | null;
/** Times this customer was seen BEFORE this visit. 0 for a first arrival. */
previousVisits: number;
attendedBy: string | null;
attendedByName: string | null;
/** Decides whether the button says Take or Continue. */
attendedByMe: boolean;
imageUrl: string | null;
}

View File

@@ -1,19 +0,0 @@
/**
* LYT reward contracts.
*
* The wire shape for this feature. Imported by BOTH its route handlers and its
* components, so a server/client drift is a type error rather than a runtime
* surprise. When a real backend arrives, this file is the negotiation artifact.
*/
export type RewardStatus = 'active' | 'paused' | 'expiring' | 'expired';
export interface Reward {
id: string;
name: string;
costLyt: number;
claimed: number;
used: number;
expiresAt: string | null;
status: RewardStatus;
}

View File

@@ -1,383 +0,0 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface ApiKeyItem extends Record<string, unknown> {
id: string;
name: string;
keyPrefix: string;
created: string;
lastUsed: string;
status: 'active' | 'revoked';
}
export interface WebhookEndpointItem extends Record<string, unknown> {
id: string;
url: string;
events: string;
secret: string;
status: 'healthy' | 'failing';
}
export interface WebhookLogItem extends Record<string, unknown> {
id: string;
event: string;
url: string;
code: number;
latency: string;
timestamp: string;
}
const INITIAL_KEYS: ApiKeyItem[] = [
{
id: 'key-1',
name: 'Production POS Key',
keyPrefix: 'lmer_live_9f8a••••••••4b12',
created: '10 Jan 2026',
lastUsed: 'Just now',
status: 'active',
},
{
id: 'key-2',
name: 'Staging Integration Key',
keyPrefix: 'lmer_test_3d2c••••••••8e91',
created: '15 Jun 2026',
lastUsed: 'Yesterday',
status: 'active',
},
];
const INITIAL_WEBHOOKS: WebhookEndpointItem[] = [
{
id: 'wh-1',
url: 'https://api.nearle.in/webhooks/loyaly',
events: 'order.created, lyt.redeemed',
secret: 'whsec_8841••••••••9932',
status: 'healthy',
},
{
id: 'wh-2',
url: 'https://hooks.zapier.com/hooks/catch/19284',
events: 'staff.checkin, store.updated',
secret: 'whsec_7712••••••••1102',
status: 'healthy',
},
];
const WEBHOOK_LOGS: WebhookLogItem[] = [
{
id: 'log-101',
event: 'lyt.redeemed',
url: 'https://api.nearle.in/webhooks/loyaly',
code: 200,
latency: '142ms',
timestamp: 'Today, 15:42:01',
},
{
id: 'log-102',
event: 'order.created',
url: 'https://api.nearle.in/webhooks/loyaly',
code: 200,
latency: '185ms',
timestamp: 'Today, 15:38:12',
},
{
id: 'log-103',
event: 'staff.checkin',
url: 'https://hooks.zapier.com/hooks/catch/19284',
code: 200,
latency: '95ms',
timestamp: 'Today, 09:01:44',
},
];
export function ApiWebhooksManager() {
const toast = useToast();
const [keys, setKeys] = useState<ApiKeyItem[]>(INITIAL_KEYS);
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(INITIAL_WEBHOOKS);
const [newKeyName, setNewKeyName] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [newWebhookUrl, setNewWebhookUrl] = useState('');
const handleGenerateKey = () => {
if (!newKeyName.trim()) {
toast({type: 'error', body: 'Key name is required'});
return;
}
const createdKey: ApiKeyItem = {
id: `key-${Date.now()}`,
name: newKeyName,
keyPrefix: `lmer_live_${Math.random().toString(36).substring(2, 6)}••••••••${Math.random().toString(36).substring(2, 6)}`,
created: 'Just now',
lastUsed: 'Never',
status: 'active',
};
setKeys((prev) => [createdKey, ...prev]);
setNewKeyName('');
setIsGenerating(false);
toast({body: `API Key "${newKeyName}" generated successfully`});
};
const handleRevokeKey = (id: string, name: string) => {
setKeys((prev) => prev.filter((k) => k.id !== id));
toast({body: `Revoked API Key "${name}"`});
};
const handleAddWebhook = () => {
if (!newWebhookUrl.trim()) {
toast({type: 'error', body: 'Webhook URL is required'});
return;
}
const newWh: WebhookEndpointItem = {
id: `wh-${Date.now()}`,
url: newWebhookUrl,
events: 'order.created, lyt.redeemed, staff.checkin',
secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`,
status: 'healthy',
};
setWebhooks((prev) => [newWh, ...prev]);
setNewWebhookUrl('');
toast({body: 'Webhook endpoint registered successfully'});
};
const keyColumns: TableColumn<ApiKeyItem>[] = [
{
key: 'name',
header: 'Key Name',
width: proportional(1.5),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.keyPrefix}
</Text>
</VStack>
),
},
{
key: 'created',
header: 'Created Date',
width: proportional(1.2),
renderCell: (row) => <Text size="sm">{row.created}</Text>,
},
{
key: 'lastUsed',
header: 'Last Active',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastUsed}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.status === 'active' ? 'success' : 'error'}
label={row.status}
/>
),
},
{
key: 'actions',
header: 'Action',
align: 'center',
width: pixel(120),
renderCell: (row) => (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeKey(row.id, row.name)}
/>
</HStack>
),
},
];
const webhookColumns: TableColumn<WebhookEndpointItem>[] = [
{
key: 'url',
header: 'Endpoint URL',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.url}
</Text>
<Text size="sm" color="secondary">
Events: {row.events}
</Text>
</VStack>
),
},
{
key: 'secret',
header: 'Signing Secret',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.secret}
</Text>
),
},
{
key: 'status',
header: 'Health',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.status === 'healthy' ? 'success' : 'error'}
label={row.status}
/>
),
},
];
const logColumns: TableColumn<WebhookLogItem>[] = [
{
key: 'event',
header: 'Event',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.event}
</Text>
),
},
{
key: 'url',
header: 'Target URL',
width: proportional(2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.url}
</Text>
),
},
{
key: 'code',
header: 'HTTP Code',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.code === 200 ? 'success' : 'error'}
label={`${row.code} OK`}
/>
),
},
{
key: 'latency',
header: 'Latency',
width: pixel(90),
renderCell: (row) => <Text size="sm">{row.latency}</Text>,
},
{
key: 'timestamp',
header: 'Timestamp',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timestamp}
</Text>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Developer API Keys"
subtitle="Manage authentication tokens for custom POS clients and server SDKs."
actions={
<Button
variant="primary"
size="sm"
label={isGenerating ? 'Cancel' : 'Generate Secret Key'}
onClick={() => setIsGenerating(!isGenerating)}
/>
}
>
<VStack gap={4}>
{isGenerating ? (
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="API Key Description"
value={newKeyName}
onChange={setNewKeyName}
placeholder="e.g. Indiranagar Counter 2 POS"
/>
<Button
variant="primary"
size="sm"
label="Generate Token"
onClick={handleGenerateKey}
/>
</HStack>
) : null}
<ResponsiveTable
columns={keyColumns}
data={keys}
primaryKey="name"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Subscriptions & Endpoints"
subtitle="Real-time HTTP POST callbacks for order, redemption and staff events."
>
<VStack gap={4}>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Add Webhook Endpoint URL"
value={newWebhookUrl}
onChange={setNewWebhookUrl}
placeholder="https://yourdomain.com/webhooks/loyaly"
/>
<Button
variant="primary"
size="sm"
label="Add Endpoint"
onClick={handleAddWebhook}
/>
</HStack>
<ResponsiveTable
columns={webhookColumns}
data={webhooks}
primaryKey="url"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Delivery Logs"
subtitle="Recent HTTP delivery attempts, latency and status response codes."
>
<ResponsiveTable
columns={logColumns}
data={WEBHOOK_LOGS}
primaryKey="event"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -1,229 +0,0 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
import {Divider} from '@astryxdesign/core/Divider';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {formatInr} from '@/shared/utils/format';
export interface InvoiceItem extends Record<string, unknown> {
id: string;
date: string;
amount: number;
status: 'paid' | 'pending';
downloadUrl: string;
}
const INVOICES: InvoiceItem[] = [
{
id: 'INV-2026-008',
date: '1 Aug 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
{
id: 'INV-2026-007',
date: '1 Jul 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
{
id: 'INV-2026-006',
date: '1 Jun 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
];
import {DownloadDropdown} from '@/shared/components/patterns/DownloadDropdown';
export function BillingOverview() {
const toast = useToast();
const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842');
const [ifsc, setIfsc] = useState('HDFC0001234');
const [isUpdatingBank, setIsUpdatingBank] = useState(false);
const handleUpdateBank = () => {
setIsUpdatingBank(true);
setTimeout(() => {
setIsUpdatingBank(false);
toast({body: 'Settlement bank account details updated'});
}, 400);
};
const columns: TableColumn<InvoiceItem>[] = [
{
key: 'id',
header: 'Invoice ID',
width: proportional(1.5),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.id}
</Text>
),
},
{
key: 'date',
header: 'Billed Date',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.date}
</Text>
),
},
{
key: 'amount',
header: 'Amount',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" weight="medium">
{formatInr(row.amount)}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(110),
renderCell: (row) => (
<Badge
variant={row.status === 'paid' ? 'success' : 'warning'}
label={row.status}
/>
),
},
{
key: 'actions',
header: 'Statement',
align: 'end',
width: pixel(180),
renderCell: (row) => (
<HStack hAlign="end">
<DownloadDropdown
filename={`Invoice_${row.id}`}
title={`Invoice Statement ${row.id}`}
subtitle={`Billed Date: ${row.date} • Amount: ₹${row.amount}`}
columns={[
{key: 'id', header: 'Invoice ID'},
{key: 'date', header: 'Billed Date'},
{key: 'amount', header: 'Amount (₹)'},
{key: 'status', header: 'Payment Status'},
]}
data={[row]}
variant="primary"
/>
</HStack>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Subscription & Quota Usage"
subtitle="Enterprise Growth Plan details, active limit quotas and payment instruments."
actions={<Badge variant="success" label="Subscription Active" />}
>
<VStack gap={4}>
<StatRow>
<StatPair label="Active Plan" value="Growth Enterprise" />
<StatPair label="Billing Frequency" value="Monthly" />
<StatPair label="Next Renewal" value="1 Sep 2026" />
<StatPair label="Recurring Amount" value={formatInr(14999)} align="end" />
</StatRow>
<Divider />
<VStack gap={3}>
<Heading level={3}>Usage Quotas</Heading>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">Store Locations (5 of 10 used)</Text>
<Text size="sm" weight="medium">
50%
</Text>
</HStack>
<ProgressBar value={50} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">Staff User Seats (5 of 15 used)</Text>
<Text size="sm" weight="medium">
33%
</Text>
</HStack>
<ProgressBar value={33} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">API Calls / Month (142,500 of 500,000)</Text>
<Text size="sm" weight="medium">
28.5%
</Text>
</HStack>
<ProgressBar value={28.5} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
</VStack>
</VStack>
</StaticPanel>
<StaticPanel
title="LYT Settlement Bank Details"
subtitle="LYTs redeemed at store counters are settled directly into your registered bank account (1 LYT = ₹1)."
actions={
<Button
variant="secondary"
size="sm"
label="Save Bank Info"
isLoading={isUpdatingBank}
onClick={handleUpdateBank}
/>
}
>
<VStack gap={4}>
<HStack gap={4} wrap="wrap">
<TextInput
label="Settlement Bank & Account"
value={bankAccount}
onChange={setBankAccount}
/>
<TextInput
label="IFSC Code"
value={ifsc}
onChange={setIfsc}
/>
</HStack>
<Text size="sm" color="secondary">
Settlement dispatches take place every Monday morning at 06:00 IST. Net liability is automatically credited.
</Text>
</VStack>
</StaticPanel>
<StaticPanel title="Invoice Statements & Payment History" subtitle="Download tax invoices and monthly receipts.">
<ResponsiveTable
columns={columns}
data={INVOICES}
primaryKey="id"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -1,213 +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 {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface IntegrationApp {
id: string;
name: string;
category: string;
description: string;
status: 'connected' | 'disconnected' | 'syncing';
apiKeyLabel?: string;
lastSync?: string;
}
const INITIAL_APPS: IntegrationApp[] = [
{
id: 'shopify',
name: 'Shopify Storefront',
category: 'E-commerce POS',
description: 'Sync online customer orders, cart totals and auto-issue LYT points.',
status: 'connected',
apiKeyLabel: 'myshopify-store.myshopify.com',
lastSync: '5 mins ago',
},
{
id: 'woocommerce',
name: 'WooCommerce',
category: 'E-commerce POS',
description: 'WordPress store integration for online checkout redemption.',
status: 'disconnected',
},
{
id: 'razorpay',
name: 'Razorpay PG',
category: 'Payment Gateway',
description: 'Automated settlement reconciliation and online UPI payment sync.',
status: 'connected',
apiKeyLabel: 'rzp_live_••••••••8841',
lastSync: 'Just now',
},
{
id: 'stripe',
name: 'Stripe Billing',
category: 'Payment Gateway',
description: 'International cards and subscription recurring billing handler.',
status: 'disconnected',
},
{
id: 'whatsapp',
name: 'WhatsApp Business API',
category: 'Messaging Channel',
description: 'Send instant transaction receipts, reward expiry notices and OTPs.',
status: 'connected',
apiKeyLabel: '+91 98450 12345 (Verified)',
lastSync: '2 mins ago',
},
{
id: 'google',
name: 'Google Business Profile',
category: 'Local Search & Maps',
description: 'Sync branch operating hours, store locations and customer reviews.',
status: 'connected',
apiKeyLabel: '5 locations synced',
lastSync: '1 hour ago',
},
{
id: 'meta',
name: 'Meta Ads & Conversions',
category: 'Marketing & Ads',
description: 'Track ad attribution, return on ad spend (ROAS) and retargeting.',
status: 'disconnected',
},
];
export function IntegrationsGrid() {
const toast = useToast();
const [apps, setApps] = useState<IntegrationApp[]>(INITIAL_APPS);
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');
const toggleConnection = (app: IntegrationApp) => {
if (app.status === 'connected') {
setApps((prev) =>
prev.map((a) =>
a.id === app.id ? {...a, status: 'disconnected', lastSync: undefined} : a,
),
);
toast({body: `Disconnected ${app.name}`});
} else {
setSelectedApp(app);
setApiKeyInput('');
}
};
const handleConfirmConnect = () => {
if (!selectedApp) return;
setApps((prev) =>
prev.map((a) =>
a.id === selectedApp.id
? {
...a,
status: 'connected',
apiKeyLabel: apiKeyInput || 'Configured',
lastSync: 'Just now',
}
: a,
),
);
toast({body: `Successfully connected ${selectedApp.name}`});
setSelectedApp(null);
};
return (
<VStack gap={5}>
<StaticPanel
title="Third-Party Integrations & Connectors"
subtitle="Connect E-commerce storefronts, payment gateways, messaging services and advertising platforms."
>
<VStack gap={4}>
{selectedApp ? (
<Card variant="muted">
<VStack gap={3}>
<Heading level={3}>Connect {selectedApp.name}</Heading>
<Text size="sm" color="secondary">
{selectedApp.description}
</Text>
<TextInput
label="API Key / Store URL / Identifier"
value={apiKeyInput}
onChange={setApiKeyInput}
placeholder="Paste live secret key or domain URL"
/>
<HStack gap={2}>
<Button
variant="primary"
size="sm"
label="Establish Connection"
onClick={handleConfirmConnect}
/>
<Button
variant="secondary"
size="sm"
label="Cancel"
onClick={() => setSelectedApp(null)}
/>
</HStack>
</VStack>
</Card>
) : null}
<Grid columns={{minWidth: 320, max: 2, repeat: 'fit'}} gap={4}>
{apps.map((app) => {
const isConn = app.status === 'connected';
return (
<Card key={app.id}>
<VStack gap={3}>
<HStack hAlign="between" vAlign="start">
<VStack gap={0.5}>
<Text weight="medium" size="sm">
{app.name}
</Text>
<Text size="sm" color="secondary">
{app.category}
</Text>
</VStack>
<Badge
variant={isConn ? 'success' : 'neutral'}
label={isConn ? 'Connected' : 'Disconnected'}
/>
</HStack>
<Text size="sm" color="secondary">
{app.description}
</Text>
{isConn && app.apiKeyLabel ? (
<HStack hAlign="between" vAlign="center">
<Text size="sm" color="secondary">
{app.apiKeyLabel}
</Text>
{app.lastSync ? (
<Text size="sm" color="secondary">
Synced {app.lastSync}
</Text>
) : null}
</HStack>
) : null}
<Button
variant={isConn ? 'secondary' : 'primary'}
size="sm"
label={isConn ? 'Disconnect' : 'Connect & Configure'}
onClick={() => toggleConnection(app)}
/>
</VStack>
</Card>
);
})}
</Grid>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -1,157 +0,0 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Switch} from '@astryxdesign/core/Switch';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface EventNotification {
id: string;
label: string;
description: string;
email: boolean;
sms: boolean;
whatsapp: boolean;
push: boolean;
}
const DEFAULT_EVENTS: EventNotification[] = [
{
id: 'dailySummary',
label: 'Daily Performance Summary',
description: 'Daily morning dispatch with footfall, revenue and LYT redemptions.',
email: true,
sms: false,
whatsapp: true,
push: true,
},
{
id: 'expiryAlerts',
label: 'Reward Expiry Warning',
description: 'Alert when active rewards are within 48 hours of expiration.',
email: true,
sms: true,
whatsapp: true,
push: true,
},
{
id: 'staffAbsence',
label: 'Staff Absence & Late Check-in',
description: 'Instant alert when a scheduled shift starts without rostered staff.',
email: true,
sms: true,
whatsapp: false,
push: true,
},
{
id: 'lowConversion',
label: 'Low Conversion Drop Alert',
description: 'Triggered if a store conversion rate drops below 15% threshold.',
email: true,
sms: false,
whatsapp: true,
push: false,
},
{
id: 'settlementReceipt',
label: 'LYT Settlement Receipts',
description: 'Weekly payout summary and bank credit confirmations.',
email: true,
sms: true,
whatsapp: true,
push: false,
},
{
id: 'securityAlerts',
label: 'Security & New Login Alerts',
description: 'Immediate notification on unrecognized IP or new device login.',
email: true,
sms: true,
whatsapp: true,
push: true,
},
];
export function NotificationsForm() {
const toast = useToast();
const [events, setEvents] = useState<EventNotification[]>(DEFAULT_EVENTS);
const [isSaving, setIsSaving] = useState(false);
const toggleChannel = (
id: string,
channel: 'email' | 'sms' | 'whatsapp' | 'push',
) => {
setEvents((prev) =>
prev.map((e) => (e.id === id ? {...e, [channel]: !e[channel]} : e)),
);
};
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setIsSaving(false);
toast({body: 'Multi-channel notification settings saved'});
}, 400);
};
return (
<StaticPanel
title="Notification Preferences & Channels"
subtitle="Configure delivery channels (Email, SMS, WhatsApp, Push) for business alerts."
actions={
<Button
variant="primary"
size="sm"
label="Save Preferences"
isLoading={isSaving}
onClick={handleSave}
/>
}
>
<VStack gap={4}>
{events.map((evt, idx) => (
<VStack key={evt.id} gap={3}>
<HStack hAlign="between" vAlign="center" wrap="wrap" gap={3}>
<VStack gap={0.5} width={380}>
<Text size="sm" weight="medium">
{evt.label}
</Text>
<Text size="sm" color="secondary">
{evt.description}
</Text>
</VStack>
<HStack gap={3} vAlign="center" wrap="wrap">
<Switch
label="Email"
value={evt.email}
onChange={() => toggleChannel(evt.id, 'email')}
/>
<Switch
label="SMS"
value={evt.sms}
onChange={() => toggleChannel(evt.id, 'sms')}
/>
<Switch
label="WhatsApp"
value={evt.whatsapp}
onChange={() => toggleChannel(evt.id, 'whatsapp')}
/>
<Switch
label="Push"
value={evt.push}
onChange={() => toggleChannel(evt.id, 'push')}
/>
</HStack>
</HStack>
{idx === events.length - 1 ? null : <Divider />}
</VStack>
))}
</VStack>
</StaticPanel>
);
}

View File

@@ -1,154 +0,0 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
/** Theme mode is deliberately absent — it lives in ThemeModeProvider. */
export interface SystemPreferences {
currency: 'INR' | 'USD' | 'EUR' | 'GBP';
language: 'en' | 'hi' | 'kn' | 'ta';
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
defaultLanding: '/dashboard' | '/stores' | '/lyts' | '/staff';
}
const DEFAULT_PREFS: SystemPreferences = {
currency: 'INR',
language: 'en',
dateFormat: 'DD/MM/YYYY',
defaultLanding: '/dashboard',
};
export function PreferencesForm() {
const toast = useToast();
/**
* Theme is the one preference that does NOT go through the save cycle.
* It repaints the workspace the instant it changes and persists itself to
* the theme-mode cookie, so there is nothing left for Save to commit — and
* a mode you can see but have not "saved" would be the confusing state.
* The rest of the form is still mock local state.
*/
const {mode: themeMode, setMode: setThemeMode} = useThemeMode();
const [prefs, setPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [savedPrefs, setSavedPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [isSaving, setIsSaving] = useState(false);
const set = <K extends keyof SystemPreferences>(
key: K,
val: SystemPreferences[K],
) => setPrefs((p) => ({...p, [key]: val}));
const isDirty = JSON.stringify(prefs) !== JSON.stringify(savedPrefs);
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setSavedPrefs(prefs);
setIsSaving(false);
toast({body: 'Workspace preferences updated'});
}, 400);
};
return (
<StaticPanel
title="Workspace Display & Formatting Preferences"
subtitle="Customize theme mode, reporting currency, language and default start screen."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="secondary"
size="sm"
label="Reset"
isDisabled={!isDirty || isSaving}
onClick={() => setPrefs(savedPrefs)}
/>
<Button
variant="primary"
size="sm"
label="Save Preferences"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={handleSave}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<Selector
label="Theme Interface Mode"
value={themeMode}
onChange={(v) => setThemeMode(v as ThemeMode)}
options={[
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'Light Mode', value: 'light'},
{label: 'System OS Preference', value: 'system'},
]}
/>
<Selector
label="Reporting Base Currency"
value={prefs.currency}
onChange={(v) => set('currency', v as SystemPreferences['currency'])}
options={[
{label: 'INR (₹ Indian Rupee)', value: 'INR'},
{label: 'USD ($ US Dollar)', value: 'USD'},
{label: 'EUR (€ Euro)', value: 'EUR'},
{label: 'GBP (£ British Pound)', value: 'GBP'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Workspace Language"
value={prefs.language}
onChange={(v) => set('language', v as SystemPreferences['language'])}
options={[
{label: 'English (US & India)', value: 'en'},
{label: 'Hindi (हिंदी)', value: 'hi'},
{label: 'Kannada (ಕನ್ನಡ)', value: 'kn'},
{label: 'Tamil (தமிழ்)', value: 'ta'},
]}
/>
<Selector
label="Date Formatting"
value={prefs.dateFormat}
onChange={(v) => set('dateFormat', v as SystemPreferences['dateFormat'])}
options={[
{label: 'DD/MM/YYYY (e.g. 05/08/2026)', value: 'DD/MM/YYYY'},
{label: 'MM/DD/YYYY (e.g. 08/05/2026)', value: 'MM/DD/YYYY'},
{label: 'YYYY-MM-DD (e.g. 2026-08-05)', value: 'YYYY-MM-DD'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Default Home Landing Module"
value={prefs.defaultLanding}
onChange={(v) => set('defaultLanding', v as SystemPreferences['defaultLanding'])}
options={[
{label: 'Dashboard Overview', value: '/dashboard'},
{label: 'Store Locations', value: '/stores'},
{label: 'Lyts & Rewards Catalogue', value: '/lyts'},
{label: 'Staff Roster & Performance', value: '/staff'},
]}
/>
</FormLayout>
</VStack>
</StaticPanel>
);
}

View File

@@ -1,221 +0,0 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface PermissionRow extends Record<string, unknown> {
module: string;
description: string;
Owner: boolean;
Manager: boolean;
Cashier: boolean;
Marketing: boolean;
Support: boolean;
}
const INITIAL_MATRIX: PermissionRow[] = [
{
module: 'Dashboard & Analytics',
description: 'View sales timeseries, footfall, conversion trends and KPIs',
Owner: true,
Manager: true,
Cashier: false,
Marketing: true,
Support: false,
},
{
module: 'Store Management',
description: 'Add, edit, disable stores and change operating hours',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'Staff Directory',
description: 'Manage staff rosters, assign stores, reset passwords',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: true,
},
{
module: 'Rewards & LYTs',
description: 'Create rewards, issue LYTs, adjust redemption rules',
Owner: true,
Manager: true,
Cashier: true,
Marketing: true,
Support: false,
},
{
module: 'Billing & Settlement',
description: 'View invoices, change payment methods, set bank account',
Owner: true,
Manager: false,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'API Keys & Webhooks',
description: 'Generate developer keys and manage webhook event triggers',
Owner: true,
Manager: false,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'Security & Audit Logs',
description: 'Revoke active sessions, 2FA settings and login history',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: false,
},
];
export function RoleMatrix() {
const toast = useToast();
const [matrix, setMatrix] = useState<PermissionRow[]>(INITIAL_MATRIX);
const [isSaving, setIsSaving] = useState(false);
const togglePermission = (
moduleName: string,
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
) => {
if (role === 'Owner') return; // Owner permissions locked
setMatrix((prev) =>
prev.map((item) =>
item.module === moduleName ? {...item, [role]: !item[role]} : item,
),
);
};
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setIsSaving(false);
toast({body: 'Role permission matrix updated successfully'});
}, 400);
};
const renderCheck = (
row: PermissionRow,
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
) => {
const isGranted = row[role];
return (
<HStack hAlign="center" width="full">
<div className="w-[108px] flex justify-center">
<Button
size="sm"
variant={isGranted ? 'primary' : 'secondary'}
isDisabled={role === 'Owner'}
label={isGranted ? 'Granted' : 'No Access'}
onClick={() => togglePermission(row.module, role)}
className="w-full flex justify-center text-center"
/>
</div>
</HStack>
);
};
const columns: TableColumn<PermissionRow>[] = [
{
key: 'module',
header: 'Module / Feature Area',
width: proportional(3),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.module}
</Text>
<Text size="sm" color="secondary">
{row.description}
</Text>
</VStack>
),
},
{
key: 'Owner',
header: 'Owner',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Owner'),
},
{
key: 'Manager',
header: 'Manager',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Manager'),
},
{
key: 'Cashier',
header: 'Cashier',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Cashier'),
},
{
key: 'Marketing',
header: 'Marketing',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Marketing'),
},
{
key: 'Support',
header: 'Support',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Support'),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Role Access Control Matrix"
subtitle="Configure granular module access levels for Owner, Manager, Cashier, Marketing and Support roles."
actions={
<Button
variant="primary"
size="sm"
label="Save Permissions"
isLoading={isSaving}
onClick={handleSave}
/>
}
>
<VStack gap={4}>
<HStack gap={2} wrap="wrap">
<Badge variant="info" label="Owner (Full Root Access)" />
<Badge variant="neutral" label="Manager (Operational Controls)" />
<Badge variant="neutral" label="Cashier (POS & Rewards)" />
<Badge variant="neutral" label="Marketing (Campaigns & Analytics)" />
<Badge variant="neutral" label="Support (Staff Roster View)" />
</HStack>
<ResponsiveTable
columns={columns}
data={matrix}
primaryKey="module"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -40,39 +40,15 @@ export const SETTINGS_NAV: SettingsSection[] = [
icon: ICONS.stores,
description: 'Manage store locations, operating hours and managers.',
},
{
label: 'Roles & Permissions',
href: '/settings/roles',
icon: ICONS.roles,
description: 'Access levels, module permissions and role matrices.',
},
{
label: 'Notifications',
href: '/settings/notifications',
icon: ICONS.notifications,
description: 'Email, SMS, WhatsApp and Push alert channels.',
},
{
label: 'Billing',
href: '/settings/billing',
icon: ICONS.revenue,
description: 'Subscription plans, payment methods and LYT settlement.',
},
{
label: 'Security',
href: '/settings/security',
icon: ICONS.security,
description: '2FA authentication, active sessions and audit history.',
},
{
label: 'Preferences',
href: '/settings/preferences',
icon: ICONS.preferences,
description: 'System theme, default currency, language and formats.',
},
];
/** Exact match only — /settings must not stay active on /settings/billing. */
/** Exact match only — /settings must not stay active on /settings/team. */
export function isSettingsActive(pathname: string, href: string): boolean {
return pathname === href;
}

View File

@@ -1,45 +0,0 @@
/**
* Staff, attendance and performance contracts.
*
* The wire shape for this feature. Imported by BOTH its route handlers and its
* components, so a server/client drift is a type error rather than a runtime
* surprise. When a real backend arrives, this file is the negotiation artifact.
*/
export type Attendance = 'present' | 'absent' | 'late' | 'leave';
export interface StaffMember {
id: string;
name: string;
avatarUrl: string | null;
/** Which store they are rostered at. */
storeId: string;
role: string;
attendance: Attendance;
salesCount: number;
rewardsIssued: number;
/** 0–100. */
performanceScore: number;
/** Share of shifts attended on time, over the selected period. */
punctualityPct: number;
}
export interface StaffSummary {
present: number;
absent: number;
late: number;
onLeave: number;
total: number;
}
export interface AttendancePoint {
t: string;
present: number;
late: number;
absent: number;
}
export interface StaffSalesPoint {
t: string;
sales: number;
}

View File

@@ -1,29 +0,0 @@
import type {Attendance} from '@/features/staff/types/staff';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Attendance is the one place in this app where semantic colour applies to
* every value rather than to an exception.
*
* Elsewhere the rule is "most things gray, colour marks what needs action".
* Here the whole vocabulary IS status: present/late/absent map cleanly onto
* success/warning/error, and a merchant scanning a roster is reading exactly
* that axis. Greying three of the four would make the set harder to scan, not
* calmer. On leave stays neutral — it is planned, so it needs no attention.
*/
export const ATTENDANCE: Record<
Attendance,
{label: string; tone: StatusDotVariant; badge: 'success' | 'warning' | 'error' | 'neutral'}
> = {
present: {label: 'Present', tone: 'success', badge: 'success'},
late: {label: 'Late', tone: 'warning', badge: 'warning'},
absent: {label: 'Absent', tone: 'error', badge: 'error'},
leave: {label: 'On leave', tone: 'neutral', badge: 'neutral'},
};
export const ATTENDANCE_ORDER: Attendance[] = [
'present',
'late',
'absent',
'leave',
];

View File

@@ -1,47 +0,0 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiCustomerCreated, ApiFloorVisit} from './types';
/**
* The shop floor and the customer form that goes with it.
*
* The lifecycle calls carry no staff id: the platform derives it from the
* session, so a request structurally cannot claim a customer on somebody
* else's behalf.
*/
export const floorApi = {
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
upstreamRequest<{items: ApiFloorVisit[]}>({path: '/api/floor/visits', query, accessToken}),
attend: (accessToken: string, visitID: string) =>
upstreamRequest<ApiFloorVisit>({
path: `/api/visits/${encodeURIComponent(visitID)}/attend`,
method: 'POST',
accessToken,
}),
release: (accessToken: string, visitID: string) =>
upstreamRequest<ApiFloorVisit>({
path: `/api/visits/${encodeURIComponent(visitID)}/release`,
method: 'POST',
accessToken,
}),
complete: (accessToken: string, visitID: string) =>
upstreamRequest<ApiFloorVisit>({
path: `/api/visits/${encodeURIComponent(visitID)}/complete`,
method: 'POST',
accessToken,
}),
createCustomer: (
accessToken: string,
body: {name: string; phone: string; notes?: string; visit_id?: string},
) =>
upstreamRequest<ApiCustomerCreated>({
path: '/api/customers',
method: 'POST',
body,
accessToken,
}),
};

View File

@@ -1,38 +0,0 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiSale, ApiSaleResult} from './types';
/**
* Sales, from the merchant application's own domain.
*
* Distinct from `purchasesApi`, which writes the older `/api/purchases` record
* the conversion report reads. Both exist on purpose: that one is still the
* only writer the current mobile flow has, and migrating it would break a
* working path while this domain has no writers yet.
*/
export const salesApi = {
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
upstreamRequest<{items: ApiSale[]}>({path: '/api/sales', query, accessToken}),
create: (
accessToken: string,
body: {
idempotency_key: string;
invoice_no: string;
site?: string;
visit_id?: string;
visitor_id?: string;
client_created_at?: string;
lines: {product_name: string; price_paise: number; intent: string}[];
},
) =>
upstreamRequest<ApiSaleResult>({
path: '/api/sales',
method: 'POST',
body,
accessToken,
}),
byId: (accessToken: string, id: string) =>
upstreamRequest<ApiSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
};

View File

@@ -1,184 +1,63 @@
/**
* Where the Loyaly platform API lives, and the rules about what may be called
* one. Pure configuration resolution: no I/O, no crypto, no `server-only`.
* Where the Loyaly platform API lives.
*
* ── Why it is not in apiClient ───────────────────────────────────────────
* apiClient is `server-only`, and that package resolves to a module which
* THROWS ON IMPORT outside a react-server condition. src/instrumentation.ts
* runs this same validation at boot and is NOT compiled in that condition, so
* importing apiClient from it would crash the server on start — for every
* deployment, correctly configured or not.
* There is ONE backend, `https://mcp.loyaly.ai`, and this console talks to it
* in every environment - development included. The console used to default to
* a backend on localhost, and the result was features built against a locally
* modified server that production never had: whole screens answered 404 the
* day they were deployed. A developer who genuinely needs another backend sets
* LOYALY_API_BASE explicitly; nothing defaults to it.
*
* Splitting it also keeps one source of truth: the boot check and the request
* path call the SAME function against the SAME allowlist, so a check that
* passes at startup cannot be contradicted by the first request.
* Server-side only and deliberately not NEXT_PUBLIC: the browser talks to this
* app's own routes, never to the platform, which is what keeps the access
* token out of JavaScript.
*
* Resolved lazily, not at module scope: `next build` imports every route
* module, and a value read at import time turns a missing runtime variable
* into a build failure.
*/
import {ConfigError} from '@/shared/errors/configError';
/**
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
* host would let a browser bypass the BFF, which is the whole point of it.
*
* ── Why there is no remote fallback ──────────────────────────────────────
* This used to default to `https://platform.loyaly.ai`, which is NOT the
* Behavision API — that host serves this very console. Measured: it answers
* `GET /api/auth/me` with the console's own 404 HTML page, and a login POST
* with the console's own `{error:{code,message}}` envelope rather than the
* platform's flat `{error,message}`. So an unset variable did not fail; it
* quietly pointed the BFF at its own origin, and every upstream call became a
* request the console made to itself.
*
* A wrong host that *works* is worse than a startup failure, so production
* refuses to SERVE without the variable — the same stance `tokenStore.ts`
* takes on AUTH_SECRET, and for the same reason. Development falls back to the
* local backend, which is the only host a dev machine can usefully mean.
*
* local http://127.0.0.1:8088
* production https://mcp.loyaly.ai
*
* ── Why this is resolved lazily and not at module scope ──────────────────
* It used to be `const BASE = resolveBase()`, evaluated the moment any module
* imported this one. That broke `next build`: the "Collecting page data" step
* imports every route module, the Docker builder stage sets NODE_ENV=production,
* and LOYALY_API_BASE is a RUNTIME value that is not present while building an
* image. So the guard fired against the build instead of against a
* misconfigured server, and the deploy failed with "Failed to collect page data
* for /api/assistant".
*
* Deferring to first use draws the line where it belongs: building an image
* needs no platform host, serving a request does. `tokenStore.key()` is a
* function for exactly this reason — this now matches it rather than only
* claiming to. The result is memoised, so the environment is read once per
* process and a healthy server pays nothing per request.
*/
const DEV_API_BASE = 'http://127.0.0.1:8088';
export const PRODUCTION_API_ORIGIN = 'https://mcp.loyaly.ai';
/**
* The only origin that serves the Loyaly platform API in production.
*
* Production is an allowlist of exactly one entry rather than a shape check,
* because "looks like a URL" is what let the wrong host through before. A new
* environment — staging, a regional deployment — is a deliberate line added
* here, not something a typo in a dashboard can invent.
*/
const PRODUCTION_API_ORIGIN = 'https://mcp.loyaly.ai';
/**
* Hosts that are definitely NOT the API, and why.
*
* Rejected in EVERY environment, development included: this is not a
* production-hardening rule, it is a statement of fact about what the host
* serves. Naming the reason matters — "rejected" alone sends somebody looking
* for a firewall or a DNS problem, when the actual fix is one word in a
* variable.
* Hosts that are definitely not the API. `platform.loyaly.ai` serves this very
* console; pointing the BFF there makes it call its own origin, which fails in
* a way that looks like a broken login form rather than a wrong variable.
*/
const KNOWN_WRONG_HOSTS: Record<string, string> = {
'platform.loyaly.ai':
'serves this console, not the Loyaly API — pointing the BFF there makes ' +
'it call its own origin',
'platform.loyaly.ai': 'serves this console, not the Loyaly API',
};
/**
* `buildUrl` — which resolves this variable — is called OUTSIDE the try block
* that turns a failed fetch into `UpstreamError(0, 'network')`. It used to be
* inside it, which made "nobody set LOYALY_API_BASE" indistinguishable from
* "the platform is down" at every call site, and had the login route report
* both as 502 platform_unreachable.
*/
function configError(detail: string): ConfigError {
return new ConfigError(
`LOYALY_API_BASE is invalid: ${detail}. ` +
`Set it to ${PRODUCTION_API_ORIGIN} in production, or ${DEV_API_BASE} locally.`,
);
}
/**
* Validate a configured value and reduce it to an origin.
*
* The path is dropped on purpose rather than preserved: `new URL(path, base)`
* has always discarded a base path, so a value like `https://host/v1` never
* did what whoever wrote it expected. Returning the origin makes that visible
* instead of silently ignored.
*/
function validateBase(raw: string, isProduction: boolean): string {
function validateBase(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw configError(`"${raw}" is not an absolute URL`);
throw new ConfigError(`LOYALY_API_BASE "${raw}" is not an absolute URL`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw configError(`"${url.protocol}" is not an http(s) URL`);
throw new ConfigError(`LOYALY_API_BASE "${raw}" is not an http(s) URL`);
}
const wrong = KNOWN_WRONG_HOSTS[url.hostname];
if (wrong) throw configError(`${url.hostname} ${wrong}`);
if (isProduction) {
if (url.origin !== PRODUCTION_API_ORIGIN) {
throw configError(
`${url.origin} is not a supported production API host`,
);
}
return url.origin;
if (wrong) throw new ConfigError(`LOYALY_API_BASE ${url.hostname} ${wrong}`);
if (process.env.NODE_ENV === 'production' && url.origin !== PRODUCTION_API_ORIGIN) {
throw new ConfigError(
`LOYALY_API_BASE ${url.origin} is not the production API (${PRODUCTION_API_ORIGIN})`,
);
}
/**
* Development stays permissive by design. A dev legitimately points this at
* a LAN address, a tunnel or a container host, and breaking that to enforce
* a production rule would cost more than it protects — nothing a dev machine
* reaches is production. The known-wrong list above still applies.
*/
// The path is dropped: `new URL(path, base)` always discarded it, so a
// value like https://host/v1 never did what its author expected.
return url.origin;
}
let cachedBase: string | null = null;
function resolveBase(): string {
/** The upstream origin, resolved on first use and memoised. */
export function resolvePlatformOrigin(): string {
if (cachedBase !== null) return cachedBase;
const isProduction = process.env.NODE_ENV === 'production';
const configured = process.env.LOYALY_API_BASE?.trim();
if (configured) {
// NOT cached before validating: an invalid value must throw on every
// request, the same way a missing one does.
return (cachedBase = validateBase(configured, isProduction));
}
/**
* ── Why an unset variable is no longer fatal in production ───────────────
* Production accepts exactly ONE origin (the allowlist below), so an unset
* LOYALY_API_BASE could never have meant anything other than that origin.
* Requiring an operator to type the single permitted value added a failure
* mode without adding a choice — and it is a failure mode that fires easily:
* `.env` ships this value inside the image, but @next/env only fills a
* variable that is ABSENT. Measured against the installed @next/env: a real
* environment variable set to the EMPTY STRING is left empty, and the file is
* not consulted. So one blank field in a dashboard defeated the shipped
* default and took production down with "required in production".
*
* This is not the remote fallback that 759f3b7 removed. That one defaulted to
* `https://platform.loyaly.ai` — the console's OWN origin, a host that is not
* the API at all and that answers wrongly instead of failing. This defaults to
* the one host the validator already insists on, and every other value,
* including that old wrong one, is still rejected by name below.
*
* The result is that production has exactly one required variable —
* AUTH_SECRET — which is the only value that genuinely cannot be shipped.
*/
if (isProduction) return (cachedBase = PRODUCTION_API_ORIGIN);
return (cachedBase = DEV_API_BASE);
// Not cached before validating: an invalid value must fail on every request.
return (cachedBase = configured ? validateBase(configured) : PRODUCTION_API_ORIGIN);
}
/**
* The upstream origin, resolved on first use and memoised. Call it; do not
* hoist it — see the note on lazy resolution above.
*/
export {resolveBase as resolvePlatformOrigin};
/** The one production origin, for messages that need to name it. */
export {PRODUCTION_API_ORIGIN};

View File

@@ -14,11 +14,7 @@ export interface NavEntry {
*/
export const PRIMARY_NAV: NavEntry[] = [
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
{label: 'Floor', href: '/floor', icon: ICONS.visitors},
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
{label: 'Store', href: '/stores', icon: ICONS.stores},
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
{label: 'Stores', href: '/stores', icon: ICONS.stores},
];
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */