Compare commits

2 Commits

Author SHA1 Message Date
0b61077e2f Stores, Team and Account run against the platform, not a mock
Stores: each shop's health in one line (offline, losing visits, camera
trouble, faces too poor - in severity order, one verdict), its cameras
as pictures with connection and 'proven to recognise a face' as two
different claims, add/edit/remove camera with the make picker, test
connection and placement checks claimed by the shop PC, and the
enrolment code a new shop PC types. Team: create an account (password
shown once), invite (code shown once), change role, remove access, reset
password, revoke pending invitations - with the rank rules the server
enforces mirrored in what the form offers. Account: who you are and
every device signed in, with 'sign out' per device and everywhere else.

Gone: StoreManagement, SecurityManager and ProfileForm that rendered
hard-coded arrays, the Business form with no backend, the /api/stores
route nothing served, and the cascading account menu's dead links.

Two things found by using the camera form, not by tests: Chrome filled
the operator's own email into 'camera username', and the first camera
saved with a password and no username because autofill wrote to the
input without React seeing it. The dialog sets autocomplete on the real
inputs and reads the credential fields from the DOM at submit.

Every route was exercised against production before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-18 12:06:28 +05:30
30d10921c2 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
2026-09-18 11:45:13 +05:30
100 changed files with 1638 additions and 5147 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 +1,15 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BusinessForm} from '@/features/settings/components/BusinessForm';
import {AccountCard} from '@/features/settings/components/AccountCard';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
import {VStack} from '@astryxdesign/core/Layout';
export default function BusinessSettingsPage() {
export default function AccountSettingsPage() {
return (
<SettingsPage
title="Business Settings"
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
>
<BusinessForm />
<SettingsPage title="Account" description="Who you are signed in as, and where.">
<VStack gap={5}>
<AccountCard />
<SecurityManager />
</VStack>
</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,33 +0,0 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/**
* Server Component: the record is read on the server and handed to the form as
* its initial state, so the inputs paint filled rather than flashing empty.
* The form then saves through the client repository over HTTP.
*
* The read goes through a repository rather than the fixture module the page
* used to import — a page that knows the shape of a mock is a page that breaks
* the day the mock is deleted.
*/
export default async function MerchantProfilePage() {
const profile = await settingsServerRepository.getProfile();
return (
<SettingsPage
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
{profile ? (
<ProfileForm initialData={profile} />
) : (
<FeatureUnavailable
title="Business profile"
description="Your sign-in details are shown above. The wider company record — business name, GSTIN, registered address, timezone and currency — is not editable here yet, so this section is left out rather than offering a form that would not save."
/>
)}
</SettingsPage>
);
}

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,13 +0,0 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
export default function SecuritySettingsPage() {
return (
<SettingsPage
title="Security & Audit Logs"
description="Two-Factor authentication, password management, active login sessions and security audit history."
>
<SecurityManager />
</SettingsPage>
);
}

View File

@@ -1,13 +0,0 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {StoreManagement} from '@/features/settings/components/StoreManagement';
export default function StoreSettingsPage() {
return (
<SettingsPage
title="Store Locations"
description="Branch operations, outlet directory, operating schedules and manager assignments."
>
<StoreManagement />
</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,94 +1,43 @@
'use client';
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 {StatusDot} from '@astryxdesign/core/StatusDot';
import {VStack} from '@astryxdesign/core/Layout';
import {Divider} from '@astryxdesign/core/Divider';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {useSites} from '@/features/stores/hooks/useSites';
import {formatPct} from '@/shared/utils/format';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {ShopSection} from '@/features/stores/components/ShopSection';
/**
* The estate, from GET /api/sites.
*
* Health fields are nullable and rendered as "—" when the platform does not
* report them. A deployment that sends no camera health is not a deployment
* with zero cameras up, and printing "0/0" for "not reported" makes a working
* estate look broken.
* The screen somebody opens to find out whether their shops are WORKING.
* Each shop: its health in one line, then its cameras as pictures, with the
* actions that make a shop real - add a camera, set up the PC, prove the
* camera can see a face.
*/
export default function StoresPage() {
const sites = useSites();
const {user} = useSession();
const canManage = user?.role === 'owner' || user?.role === 'manager';
return (
<VStack gap={5}>
<PageHeader
title="Store"
description="Every shop in the network, with its camera health."
/>
<VStack gap={6}>
<PageHeader title="Stores" description="Every shop, whether it is working, and its cameras." />
<AsyncBoundary
resource={sites}
loading={<SkeletonCardGrid count={4} height={170} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Shops appear here once they are registered on the platform."
/>
}
loading={<SkeletonCardGrid count={2} height={170} />}
empty={<EmptyPanel icon="stores" title="No stores yet" description="Loyaly registers your shops. Contact support to add one." />}
>
{(rows) => (
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
{rows.map((site) => (
<Card key={site.id}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>{site.name}</Heading>
{site.isOnline === null ? null : (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={site.isOnline ? 'success' : 'error'}
label={site.isOnline ? 'Online' : 'Offline'}
/>
<Text size="xsm" color="secondary">
{site.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
)}
</HStack>
<Text size="sm" color="secondary" className="font-mono">
{site.id}
</Text>
<StatRow>
<StatPair
label="Cameras up"
value={
site.camerasUp === null || site.camerasTotal === null
? '—'
: `${site.camerasUp}/${site.camerasTotal}`
}
/>
<StatPair
label="Below gate"
value={
site.fractionBelowGate === null
? '—'
: formatPct(site.fractionBelowGate * 100, 0)
}
align="end"
/>
</StatRow>
</VStack>
</Card>
<VStack gap={8}>
{rows.map((site, i) => (
<VStack key={site.uuid} gap={6}>
{i > 0 && <Divider />}
<ShopSection site={site} canManage={canManage} />
</VStack>
))}
</Grid>
</VStack>
)}
</AsyncBoundary>
</VStack>

View File

@@ -0,0 +1,10 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
}

View File

@@ -0,0 +1,10 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** Keeps the calling device signed in; everything else is out immediately. */
export async function POST(req: NextRequest) {
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
}

View File

@@ -0,0 +1,10 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** GET /api/auth/sessions - every device signed in as this person. */
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token) => authApi.sessions(token));
}

View File

@@ -0,0 +1,17 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
export const dynamic = 'force-dynamic';
/** POST /api/cameras/{id}/check {kind: connection|placement}. The shop PC
* claims the job on its next sync; poll the camera list for the result. */
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(
req,
(token, body) => sitesApi.checkCamera(token, id, body.kind === 'placement' ? 'placement' : 'connection'),
{map: toCamera, status: 202},
);
}

View File

@@ -0,0 +1,21 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import type {ApiCameraInput} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
export const dynamic = 'force-dynamic';
type Ctx = {params: Promise<{id: string}>};
export async function PATCH(req: NextRequest, ctx: Ctx) {
const {id} = await ctx.params;
return proxyUpstream(req, (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput), {
map: toCamera,
});
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const {id} = await ctx.params;
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
}

View File

@@ -0,0 +1,26 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import type {ApiCameraInput} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
export const dynamic = 'force-dynamic';
/** GET /api/cameras?site=<slug> - every camera, or one shop's. */
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token, _body, params) => sitesApi.cameras(token, params.get('site') || undefined), {
map: (cams) => cams.map(toCamera),
});
}
/** POST /api/cameras {site, ...camera} - add a camera to a shop. */
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) => {
const {site, ...input} = body as {site?: string} & ApiCameraInput;
return sitesApi.addCamera(token, String(site ?? ''), input);
},
{map: toCamera, status: 201},
);
}

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,57 +0,0 @@
import type {NextRequest} from 'next/server';
import {upstreamRaw} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
*
* A browser `<img>` cannot send an Authorization header, and the platform's
* own image URLs require one. The alternatives were fetch + createObjectURL +
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
* photograph on a screen left open all afternoon — or this: one hop through
* the origin that already holds the token.
*
* ── Why `src` is validated rather than trusted ───────────────────────────
* An unchecked pass-through would be an open proxy that attaches the
* merchant's bearer token to any URL an attacker can get into a page. Only
* same-origin platform paths under /api/faces/ are forwarded.
*
* Every hand-out of a photo is written to the platform's audit log, so this
* must be requested once per screen rather than once per component: two
* components asking for the same face puts two rows in "who looked at my
* customers" for one glance at one person.
*/
export async function GET(req: NextRequest) {
const src = new URL(req.url).searchParams.get('src') ?? '';
// Relative, no traversal, and inside the faces namespace. Anything else is
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
// attacker-supplied.
if (!src.startsWith('/api/faces/') || src.includes('..')) {
return Response.json(
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
{status: 400},
);
}
try {
const upstream = await withUpstream((token) =>
upstreamRaw({path: src, accessToken: token}),
);
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: this is one merchant's customer, and a shared cache holding
// it would serve it across tenants.
'cache-control': 'private, max-age=300',
},
});
} catch (err) {
return failResponse(err);
}
}

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

@@ -0,0 +1,38 @@
import type {NextRequest} from 'next/server';
import {upstreamRaw} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* The browser cannot put a bearer on an <img>, so pictures the platform
* serves with the session - faces, camera snapshots - come through here.
* Presigned bucket links are absolute and load directly; they never come here.
*/
const ALLOWED = [/^\/api\/faces\/[^/?]+$/, /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/, /^\/api\/visitors\/[^/?]+\/image$/];
export function isProxyableImage(src: string): boolean {
return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
}
export async function GET(req: NextRequest) {
const src = req.nextUrl.searchParams.get('src') ?? '';
if (!isProxyableImage(src)) {
return Response.json({error: {code: 'bad_request', message: 'Not a valid image reference.'}}, {status: 400});
}
try {
const upstream = await withUpstream((token) => upstreamRaw({path: src, accessToken: token}));
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: one merchant's customer or shop floor. A shared cache
// holding it would serve it across tenants.
'cache-control': 'private, max-age=60',
},
});
} catch (err) {
return failResponse(err);
}
}

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

@@ -0,0 +1,19 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** POST /api/sites/{site}/enrolment-code - the code a new shop PC types. */
export async function POST(req: NextRequest, ctx: {params: Promise<{site: string}>}) {
const {site} = await ctx.params;
return proxyUpstream(
req,
(token, body) =>
sitesApi.enrolmentCode(token, site, {
label: typeof body.label === 'string' ? body.label : undefined,
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
}),
{status: 201},
);
}

View File

@@ -6,33 +6,24 @@ import type {Site} from '@/features/stores/types/site';
export const dynamic = 'force-dynamic';
/**
* GET /api/sites — the estate.
*
* This is the most load-bearing read in the console: the site switcher scopes
* every other request in the app, so a hardcoded list here meant every screen
* was filtered by a store that might not exist.
*
* `slug` is carried through as the identifier the UI keys on because it is
* IMMUTABLE upstream and safe to persist in a URL or a saved report, while the
* display name is expected to change.
*/
function toSite(s: ApiSite): Site {
return {
// Slug first: it is immutable and is what every scoped request sends as
// `?site=`. `site_id` is the uuid — the server does not send a bare `id`.
id: s.slug || s.site_id,
id: s.slug,
uuid: s.site_id,
name: s.name,
isOnline: s.online ?? null,
camerasTotal: s.cameras_total ?? null,
camerasUp: s.cameras_up ?? null,
fractionBelowGate: s.fraction_below_gate ?? null,
timezone: s.timezone,
isOnline: s.online,
lastHeartbeatAt: s.last_heartbeat_at ?? null,
lastEventAt: s.last_event_at ?? null,
recognitionModel: s.recognition_model ?? null,
camerasTotal: s.cameras_total,
camerasUp: s.cameras_up,
fractionBelowGate: s.fraction_below_gate,
queued: s.queued,
dropped: s.dropped,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => sitesApi.list(token), (sites) =>
sites.map(toSite),
);
return serveUpstream(req, (token) => sitesApi.list(token), (sites) => sites.map(toSite));
}

View File

@@ -0,0 +1,13 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** POST /api/team/{id}/password - new password shown ONCE; signs them out everywhere. */
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(req, (token, body) =>
teamApi.resetPassword(token, id, typeof body.password === 'string' && body.password ? body.password : undefined),
);
}

View File

@@ -0,0 +1,18 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** PATCH /api/team/{id} {role?, active?}. Deactivating revokes every session
* that person holds, in the same transaction, server-side. */
export async function PATCH(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(req, (token, body) =>
teamApi.update(token, id, {
role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
active: typeof body.active === 'boolean' ? body.active : undefined,
}),
);
}

View File

@@ -0,0 +1,10 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
}

View File

@@ -0,0 +1,25 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token) => teamApi.invitations(token));
}
/** POST /api/team/invitations - the code is in the response ONCE. */
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.invite(token, {
email: String(body.email ?? ''),
full_name: typeof body.full_name === 'string' ? body.full_name : undefined,
role: String(body.role ?? 'staff') as ApiRole,
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
}),
{status: 201},
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** POST /api/team/members - create an account. The password is in the
* response ONCE and nowhere else. */
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.createMember(token, {
email: String(body.email ?? ''),
full_name: String(body.full_name ?? ''),
role: String(body.role ?? 'staff') as ApiRole,
password: typeof body.password === 'string' && body.password ? body.password : undefined,
}),
{status: 201},
);
}

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

@@ -38,7 +38,7 @@ function toArrival(a: ApiArrival): Arrival {
url: a.image.url
? a.image.url.startsWith('http')
? a.image.url
: `/api/faces?src=${encodeURIComponent(a.image.url)}`
: `/api/images?src=${encodeURIComponent(a.image.url)}`
: null,
reason: a.image.reason ?? null,
}

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

@@ -0,0 +1,31 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
import {Card} from '@astryxdesign/core/Card';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {roleLabel} from '@/features/team/components/AddMemberDialog';
/** Who you are signed in as. Read-only: the platform has no profile editor
* yet, and a form that does not save is worse than none. */
export function AccountCard() {
const {user} = useSession();
if (!user) return null;
return (
<Card>
<HStack gap={3} vAlign="center">
<Avatar name={user.name} size="lg" tooltip={false} />
<VStack gap={0.5}>
<HStack gap={2} vAlign="center">
<Text size="lg" weight="medium">{user.name}</Text>
<Badge variant="neutral" label={roleLabel(user.role)} />
</HStack>
<Text size="sm" color="secondary">{user.email}</Text>
<Text size="sm" color="secondary">{user.organisation}</Text>
</VStack>
</HStack>
</Card>
);
}

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,175 +0,0 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {NumberInput} from '@astryxdesign/core/NumberInput';
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 BusinessData {
businessName: string;
legalEntity: string;
category: string;
gstin: string;
pan: string;
phone: string;
email: string;
address: string;
city: string;
pincode: string;
lytsPerHundred: number;
}
const DEFAULT_BUSINESS: BusinessData = {
businessName: 'Loyaly Retail Pvt Ltd',
legalEntity: 'Private Limited Company',
category: 'Retail & Quick Service Restaurant',
gstin: '29AABCL1234M1Z7',
pan: 'AABCL1234M',
phone: '+91 98450 12345',
email: 'aravind@nearle.in',
address: '100 Feet Road, Indiranagar',
city: 'Bengaluru, Karnataka',
pincode: '560038',
lytsPerHundred: 5,
};
export function BusinessForm() {
const toast = useToast();
const [form, setForm] = useState<BusinessData>(DEFAULT_BUSINESS);
const [isSaving, setIsSaving] = useState(false);
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
setForm((f) => ({...f, [key]: val}));
const isDirty = JSON.stringify(form) !== JSON.stringify(savedData);
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setSavedData(form);
setIsSaving(false);
toast({body: 'Business details updated successfully'});
}, 400);
};
return (
<StaticPanel
title="Business Information"
subtitle="Official company details, tax registration and operating address."
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={() => setForm(savedData)}
/>
<Button
variant="primary"
size="sm"
label="Save changes"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={handleSave}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<TextInput
label="Business name"
value={form.businessName}
onChange={(v) => set('businessName', v)}
isRequired
/>
<Selector
label="Legal entity structure"
value={form.legalEntity}
onChange={(v) => set('legalEntity', v)}
options={[
'Private Limited Company',
'Sole Proprietorship',
'Partnership Firm',
'Limited Liability Partnership (LLP)',
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="GSTIN"
value={form.gstin}
onChange={(v) => set('gstin', v.toUpperCase())}
description="15-digit Tax Registration Number"
/>
<TextInput
label="PAN"
value={form.pan}
onChange={(v) => set('pan', v.toUpperCase())}
description="10-digit Permanent Account Number"
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Support email"
value={form.email}
onChange={(v) => set('email', v)}
isRequired
/>
<TextInput
label="Support phone"
value={form.phone}
onChange={(v) => set('phone', v)}
/>
</FormLayout>
<Divider />
<FormLayout direction="horizontal">
<TextInput
label="Registered address"
value={form.address}
onChange={(v) => set('address', v)}
/>
<TextInput
label="City & State"
value={form.city}
onChange={(v) => set('city', v)}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Pincode"
value={form.pincode}
onChange={(v) => set('pincode', v)}
/>
<NumberInput
label="Default LYTs per ₹100 spent"
value={form.lytsPerHundred}
onChange={(v) => set('lytsPerHundred', v ?? 0)}
min={0}
max={100}
description="1 LYT = ₹1. Basket ₹1,000 earns 50 LYTs (5%)."
/>
</FormLayout>
</VStack>
</StaticPanel>
);
}

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,165 +0,0 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {NumberInput} from '@astryxdesign/core/NumberInput';
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';
import {settingsService} from '@/features/settings/services/settingsService';
import type {ProfileFieldErrors} from '@/features/settings/services/settingsService';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Merchant profile.
*
* `initialData` arrives from a Server Component that read the record
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
* rather than flashing empty inputs. This is the hybrid path the data layer
* was designed for: dashboards fetch on the client because their scope
* changes constantly, but a settings form has exactly one server-known state
* at page load and should use it.
*
* Status is only ever set on submit, never at initial paint — Astryx's
* FieldStatus breaks SSR hydration when it is server-rendered with a status
* already applied (see AGENTS.md).
*/
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
const toast = useToast();
const [form, setForm] = useState(initialData);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ProfileFieldErrors>({});
const set = <K extends keyof MerchantProfile>(
key: K,
value: MerchantProfile[K],
) => setForm((f) => ({...f, [key]: value}));
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
async function save() {
// Validation and transport both live in settingsService — this component
// decides what to render, never what a valid profile is or where it goes.
// The raw fetch that used to sit here was the last one in the codebase.
setIsSaving(true);
const result = await settingsService.saveProfile(form);
setIsSaving(false);
if (result.ok) {
setErrors({});
toast({body: 'Profile saved'});
return;
}
setErrors(result.errors);
if (result.message) {
toast({type: 'error', body: result.message});
}
}
return (
<StaticPanel
title="Business profile"
subtitle="Shown on receipts and used for LYT settlement."
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={() => {
setForm(initialData);
setErrors({});
}}
/>
<Button
variant="primary"
size="sm"
label="Save changes"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={save}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<TextInput
label="Business name"
value={form.businessName}
onChange={(v) => set('businessName', v)}
isRequired
status={
errors.businessName
? {type: 'error', message: errors.businessName}
: undefined
}
/>
<TextInput
label="Contact name"
value={form.contactName}
onChange={(v) => set('contactName', v)}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Email"
type="email"
value={form.email}
onChange={(v) => set('email', v)}
isRequired
status={
errors.email ? {type: 'error', message: errors.email} : undefined
}
/>
<TextInput
label="Phone"
value={form.phone}
onChange={(v) => set('phone', v)}
/>
</FormLayout>
<Divider />
<FormLayout direction="horizontal">
<TextInput
label="GSTIN"
value={form.gstin}
onChange={(v) => set('gstin', v.toUpperCase())}
description="15-character GST registration number"
status={
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
}
/>
<TextInput
label="Timezone"
value={form.timezone}
onChange={(v) => set('timezone', v)}
description="All reports are rendered in this zone"
/>
</FormLayout>
<NumberInput
label="LYTs per ₹100 spent"
value={form.lytsPerHundred}
onChange={(v) => set('lytsPerHundred', v ?? 0)}
min={0}
max={100}
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
width={320}
/>
</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

@@ -1,306 +1,70 @@
'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 {Switch} from '@astryxdesign/core/Switch';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface ActiveSessionItem extends Record<string, unknown> {
id: string;
device: string;
location: string;
ip: string;
lastActive: string;
isCurrent: boolean;
}
export interface LoginAuditItem extends Record<string, unknown> {
id: string;
user: string;
ip: string;
device: string;
status: 'success' | 'failed';
timestamp: string;
}
const INITIAL_SESSIONS: ActiveSessionItem[] = [
{
id: 'sess-1',
device: 'macOS Chrome 126',
location: 'Bengaluru, India',
ip: '103.15.24.81',
lastActive: 'Active Now',
isCurrent: true,
},
{
id: 'sess-2',
device: 'iOS Safari 17',
location: 'Bengaluru, India',
ip: '49.207.210.12',
lastActive: '2 hours ago',
isCurrent: false,
},
{
id: 'sess-3',
device: 'Windows Edge 125',
location: 'Bengaluru, India',
ip: '106.51.72.19',
lastActive: 'Yesterday, 18:40',
isCurrent: false,
},
];
const AUDIT_LOGS: LoginAuditItem[] = [
{
id: 'aud-1',
user: 'aravind@nearle.in',
ip: '103.15.24.81',
device: 'macOS Chrome',
status: 'success',
timestamp: 'Today, 14:02',
},
{
id: 'aud-2',
user: 'vikram@nearle.in',
ip: '106.51.72.19',
device: 'Windows Chrome',
status: 'success',
timestamp: 'Today, 11:15',
},
{
id: 'aud-3',
user: 'aravind@nearle.in',
ip: '185.220.101.5',
device: 'Unknown Linux Device',
status: 'failed',
timestamp: 'Yesterday, 22:30',
},
];
import {useResource} from '@/shared/hooks/useResource';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {securityRepository} from '@/features/settings/repositories/securityRepository';
import type {DeviceSession} from '@/features/settings/repositories/securityRepository';
/**
* Every device signed in as this person, and the button that ends one.
* This is the benefit of session tokens over JWTs, finally collected: staff
* phones and shop PCs get lost, resold and shared, so "sign that one out,
* now" has to actually work - and it does, immediately, server-side.
*/
export function SecurityManager() {
const sessions = useResource(securityRepository.sessions());
const toast = useToast();
const [twoFactor, setTwoFactor] = useState(true);
const [sessions, setSessions] = useState<ActiveSessionItem[]>(INITIAL_SESSIONS);
const [currentPass, setCurrentPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
const [isChangingPass, setIsChangingPass] = useState(false);
const handleChangePassword = () => {
if (!currentPass || !newPass) {
toast({type: 'error', body: 'Please fill in all password fields'});
return;
}
if (newPass !== confirmPass) {
toast({type: 'error', body: 'New passwords do not match'});
return;
}
setIsChangingPass(true);
setTimeout(() => {
setIsChangingPass(false);
setCurrentPass('');
setNewPass('');
setConfirmPass('');
toast({body: 'Password updated successfully'});
}, 400);
};
async function revoke(s: DeviceSession) {
const res = await securityRepository.revoke(s.id);
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
sessions.refetch();
}
const handleRevokeSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
toast({body: 'Session revoked successfully'});
};
async function revokeOthers() {
if (!window.confirm('Sign out every other device? This one stays signed in.')) return;
const res = await securityRepository.revokeOthers();
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
else toast({body: 'Every other device has been signed out.'});
sessions.refetch();
}
const handleRevokeAllOther = () => {
setSessions((prev) => prev.filter((s) => s.isCurrent));
toast({body: 'All other active sessions have been terminated'});
};
const sessionColumns: TableColumn<ActiveSessionItem>[] = [
{
key: 'device',
header: 'Device / Browser',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? <Badge variant="success" label="Current Session" /> : null}
</HStack>
<Text size="sm" color="secondary">
{row.location} • {row.ip}
</Text>
</VStack>
),
},
{
key: 'lastActive',
header: 'Last Active',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastActive}
</Text>
),
},
{
key: 'actions',
header: 'Action',
align: 'center',
width: pixel(120),
renderCell: (row) =>
row.isCurrent ? null : (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeSession(row.id)}
/>
</HStack>
),
},
];
const auditColumns: TableColumn<LoginAuditItem>[] = [
{
key: 'user',
header: 'Account User',
width: proportional(1.5),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.user}
</Text>
),
},
{
key: 'device',
header: 'Device & IP',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm">{row.device}</Text>
<Text size="sm" color="secondary">
{row.ip}
</Text>
</VStack>
),
},
{
key: 'status',
header: 'Result',
width: pixel(110),
renderCell: (row) => (
<Badge
variant={row.status === 'success' ? 'success' : 'error'}
label={row.status === 'success' ? 'Success' : 'Failed'}
/>
),
},
{
key: 'timestamp',
header: 'Timestamp',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timestamp}
</Text>
),
},
];
const others = (sessions.data ?? []).filter((s) => !s.current).length;
return (
<VStack gap={5}>
<StaticPanel
title="Two-Factor Authentication (2FA)"
subtitle="Require an authenticator app code (TOTP) or SMS OTP during sign-in."
actions={<Badge variant={twoFactor ? 'success' : 'warning'} label={twoFactor ? '2FA Enabled' : '2FA Disabled'} />}
>
<PanelCard
title="Signed-in devices"
subtitle="Where your account is currently signed in."
actions={others > 0 ? <Button size="sm" variant="secondary" label="Sign out everywhere else" onClick={() => void revokeOthers()} /> : undefined}
resource={sessions}
loading={<SkeletonRows count={3} />}
>
{(rows) => (
<VStack gap={3}>
<Switch
label="Enable Two-Factor Authentication"
description="Protect your merchant account with an extra verification step on new devices."
value={twoFactor}
onChange={(v) => {
setTwoFactor(v);
toast({body: `2FA ${v ? 'enabled' : 'disabled'}`});
}}
/>
{rows.map((s) => (
<HStack key={s.id} gap={3} hAlign="between" vAlign="center">
<VStack gap={0}>
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">{s.device || 'Unknown device'}</Text>
{s.current && <Badge variant="success" label="This device" />}
</HStack>
<Text size="xsm" color="secondary">
Signed in <Timestamp value={s.created_at} format="relative" />
{s.last_used_at && <> · last used <Timestamp value={s.last_used_at} format="relative" /></>}
</Text>
</VStack>
{!s.current && <Button size="sm" variant="ghost" label="Sign out" onClick={() => void revoke(s)} />}
</HStack>
))}
</VStack>
</StaticPanel>
<StaticPanel
title="Change Password"
subtitle="Ensure your password is at least 12 characters long with mixed case and numbers."
actions={
<Button
variant="primary"
size="sm"
label="Update Password"
isLoading={isChangingPass}
onClick={handleChangePassword}
/>
}
>
<VStack gap={4}>
<TextInput
label="Current Password"
type="password"
value={currentPass}
onChange={setCurrentPass}
/>
<HStack gap={4} wrap="wrap">
<TextInput
label="New Password"
type="password"
value={newPass}
onChange={setNewPass}
/>
<TextInput
label="Confirm New Password"
type="password"
value={confirmPass}
onChange={setConfirmPass}
/>
</HStack>
</VStack>
</StaticPanel>
<StaticPanel
title="Active Sessions"
subtitle="Devices currently logged into your Loyaly merchant workspace."
actions={
<Button
variant="secondary"
size="sm"
label="Revoke Other Sessions"
onClick={handleRevokeAllOther}
/>
}
>
<ResponsiveTable
columns={sessionColumns}
data={sessions}
primaryKey="device"
/>
</StaticPanel>
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable
columns={auditColumns}
data={AUDIT_LOGS}
primaryKey="user"
/>
</StaticPanel>
</VStack>
)}
</PanelCard>
);
}

View File

@@ -1,277 +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 {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
export interface StoreItem extends Record<string, unknown> {
id: string;
name: string;
location: string;
manager: string;
staffCount: number;
hours: string;
status: 'active' | 'maintenance' | 'disabled';
}
const INITIAL_STORES: StoreItem[] = [
{
id: 'blr-indiranagar',
name: 'Indiranagar Flagship',
location: '100 Feet Road, Indiranagar, Bengaluru',
manager: 'Vikram Seth',
staffCount: 14,
hours: '08:00 – 23:00',
status: 'active',
},
{
id: 'blr-koramangala',
name: 'Koramangala 80ft',
location: '80 Feet Road, Koramangala 4th Block, Bengaluru',
manager: 'Priya Sharma',
staffCount: 9,
hours: '09:00 – 22:30',
status: 'active',
},
{
id: 'blr-whitefield',
name: 'Whitefield Main',
location: 'ITPL Main Road, Whitefield, Bengaluru',
manager: 'Deepa Nair',
staffCount: 6,
hours: '10:00 – 22:00',
status: 'maintenance',
},
{
id: 'blr-jayanagar',
name: 'Jayanagar 4th Block',
location: '11th Main Rd, Jayanagar, Bengaluru',
manager: 'Suresh T',
staffCount: 8,
hours: '09:00 – 21:30',
status: 'active',
},
{
id: 'blr-mgroad',
name: 'MG Road Express',
location: 'Church Street, Off MG Road, Bengaluru',
manager: 'Unassigned',
staffCount: 0,
hours: '10:00 – 21:00',
status: 'disabled',
},
];
export function StoreManagement() {
const toast = useToast();
const [stores, setStores] = useState<StoreItem[]>(INITIAL_STORES);
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [manager, setManager] = useState('Vikram Seth');
const [hours, setHours] = useState('09:00 – 22:00');
const handleAddStore = () => {
if (!name.trim()) {
toast({type: 'error', body: 'Store name is required'});
return;
}
const newStore: StoreItem = {
id: `blr-${name.toLowerCase().replace(/\s+/g, '')}`,
name,
location: location || 'Bengaluru, Karnataka',
manager,
staffCount: 1,
hours,
status: 'active',
};
setStores((prev) => [newStore, ...prev]);
setName('');
setLocation('');
setIsAdding(false);
toast({body: `Store "${name}" added successfully`});
};
const handleToggleStatus = (id: string) => {
setStores((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const next = s.status === 'active' ? 'disabled' : 'active';
toast({body: `${s.name} status updated to ${next}`});
return {...s, status: next};
}),
);
};
const handleRemove = (id: string, storeName: string) => {
setStores((prev) => prev.filter((s) => s.id !== id));
toast({body: `Store "${storeName}" removed`});
};
const columns: TableColumn<StoreItem>[] = [
{
key: 'name',
header: 'Store Location',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.location}
</Text>
</VStack>
),
},
{
key: 'manager',
header: 'Store Manager',
width: proportional(1.2),
renderCell: (row) => <Text size="sm">{row.manager}</Text>,
},
{
key: 'hours',
header: 'Operating Hours',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.hours}
</Text>
),
},
{
key: 'staffCount',
header: 'Staff Count',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.staffCount} staff
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(120),
renderCell: (row) => {
const variant =
row.status === 'active'
? 'success'
: row.status === 'maintenance'
? 'warning'
: 'error';
return <Badge variant={variant} label={row.status} />;
},
},
{
key: 'actions',
header: 'Actions',
align: 'center',
width: pixel(160),
renderCell: (row) => (
<HStack hAlign="center">
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}}
menuWidth={180}
items={[
{
label: row.status === 'active' ? 'Disable store' : 'Enable store',
onClick: () => handleToggleStatus(row.id),
},
{type: 'divider'},
{
label: 'Remove store',
onClick: () => handleRemove(row.id, row.name),
},
]}
/>
</HStack>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Store Locations & Outlets"
subtitle="Manage active branches, operating hours, assigned managers and status."
actions={
<Button
variant="primary"
size="sm"
label={isAdding ? 'Cancel' : 'Add Store Location'}
onClick={() => setIsAdding(!isAdding)}
/>
}
>
<VStack gap={4}>
{isAdding ? (
<VStack gap={3} padding={4}>
<Heading level={3}>Add New Branch Location</Heading>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Store Name"
value={name}
onChange={setName}
placeholder="e.g. HSR Layout 27th Main"
/>
<TextInput
label="Address / Landmark"
value={location}
onChange={setLocation}
placeholder="HSR Layout, Bengaluru"
/>
<Selector
label="Store Manager"
value={manager}
onChange={(v) => setManager(v)}
options={[
'Vikram Seth',
'Priya Sharma',
'Deepa Nair',
'Unassigned',
]}
/>
<TextInput
label="Hours"
value={hours}
onChange={setHours}
placeholder="09:00 – 22:00"
/>
<Button
variant="primary"
size="sm"
label="Create Store"
onClick={handleAddStore}
/>
</HStack>
</VStack>
) : null}
<ResponsiveTable
columns={columns}
data={stores}
primaryKey="name"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
@@ -7,145 +8,200 @@ import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Button} from '@astryxdesign/core/Button';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {useToast} from '@astryxdesign/core/Toast';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useTeam} from '@/features/team/hooks/useTeam';
import type {TeamMember} from '@/features/team/types/team';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {useTeam, useInvitations} from '@/features/team/hooks/useTeam';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import {AddMemberDialog, roleLabel} from '@/features/team/components/AddMemberDialog';
import type {TeamMember, Invitation} from '@/features/team/types/team';
import type {UserRole} from '@/features/auth/types/auth';
/**
* Who has access to this console.
*
* ── What this replaced ───────────────────────────────────────────────────
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
* held in `useState`, with store names (`Indiranagar Flagship`,
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
* `Marketing`, `Support`) that do not exist upstream, and four controls that
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
* local state and were lost on refresh. A merchant could not tell any of it
* from the real thing.
*
* ── Why this screen is read-only ─────────────────────────────────────────
* Accounts are created by INVITATION, and that flow already exists in the
* platform's own web app: a manager mints a code, the holder redeems it and
* chooses their own password. Adding a second way to create an account here
* would be a second place a login can be born, on the one surface where that
* matters most. So this lists the team and does not mint anyone.
*
* Role changes and deactivation are supported upstream by
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
* revokes every session that person holds immediately, so it wants a
* confirmation step and `409 last_owner` handling, neither of which belongs in
* a change whose purpose is removing invented data.
*/
interface TeamRow extends Record<string, unknown> {
interface Row extends Record<string, unknown> {
id: string;
member: TeamMember;
name: string;
email: string;
role: string;
status: string;
lastLogin: string;
}
/**
* Read off the payload, never inferred. The platform sends a boolean; there is
* no "invited" or "suspended" state on this endpoint, so the screen does not
* invent one.
*/
function toRow(m: TeamMember): TeamRow {
return {
id: m.id,
name: m.name,
email: m.email,
role: m.role,
status: m.active ? 'Active' : 'Inactive',
// "Never" is a fact worth showing: an account created and never used is
// exactly the row a manager wants to notice.
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
};
}
const COLUMNS: TableColumn<TeamRow>[] = [
{
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
},
{
key: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
];
export function TeamManagement() {
const {user} = useSession();
const team = useTeam();
const invitations = useInvitations();
const toast = useToast();
const [adding, setAdding] = useState(false);
const [secret, setSecret] = useState<{title: string; label: string; value: string; hint: string} | null>(null);
const me = user?.role ?? 'staff';
const canManage = me === 'owner' || me === 'manager';
async function setRole(m: TeamMember, role: UserRole) {
const res = await teamRepository.update(m.id, {role});
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not change the role.'});
team.refetch();
}
async function setActive(m: TeamMember, active: boolean) {
if (!active && !window.confirm(`Remove ${m.name}'s access now? They are signed out of every device immediately.`)) return;
const res = await teamRepository.update(m.id, {active});
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not change access.'});
team.refetch();
}
async function resetPassword(m: TeamMember) {
if (!window.confirm(`Reset ${m.name}'s password? Their current one stops working and they are signed out everywhere.`)) return;
const res = await teamRepository.resetPassword(m.id);
if (!res.ok || !res.data) return toast({type: 'error', body: res.message ?? 'Could not reset the password.'});
setSecret({
title: `New password for ${m.name}`,
label: `${m.email} signs in with this from now on:`,
value: res.data.password,
hint: 'Give it to them directly. They can change it from their own Security settings.',
});
}
async function revoke(inv: Invitation) {
const res = await teamRepository.revokeInvitation(inv.id);
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not revoke the invitation.'});
invitations.refetch();
}
const columns: TableColumn<Row>[] = [
{
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">{row.name}{row.member.id === user?.id ? ' (you)' : ''}</Text>
<Text size="xsm" color="secondary">{row.member.email}</Text>
</VStack>
</HStack>
),
},
{key: 'role', header: 'Role', width: proportional(1), renderCell: (row) => <Badge variant="neutral" label={row.role} />},
{
key: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot variant={row.member.active ? 'success' : 'error'} label={row.status} />
<Text size="sm" color="secondary">{row.status}</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1)},
...(canManage
? [{
key: 'actions',
header: '',
width: proportional(1),
align: 'end' as const,
renderCell: (row: Row) => {
const m = row.member;
const self = m.id === user?.id;
// An owner outranks a manager; a manager may not touch an owner,
// and nobody edits themselves here - that is what Security is for.
const allowed = !self && (me === 'owner' || m.role !== 'owner');
if (!allowed) return null;
const roleItems = (me === 'owner' ? (['staff', 'manager', 'owner'] as UserRole[]) : (['staff', 'manager'] as UserRole[]))
.filter((r) => r !== m.role)
.map((r) => ({label: `Make ${roleLabel(r).toLowerCase()}`, onClick: () => void setRole(m, r)}));
return (
<DropdownMenu
button={{label: 'Manage', variant: 'ghost', size: 'sm'}}
items={[
...roleItems,
{type: 'divider'},
{label: 'Reset password', onClick: () => void resetPassword(m)},
m.active
? {label: 'Remove access', onClick: () => void setActive(m, false)}
: {label: 'Restore access', onClick: () => void setActive(m, true)},
]}
/>
);
},
}]
: []),
];
const toRow = (m: TeamMember): Row => ({
id: m.id,
member: m,
name: m.name,
role: roleLabel(m.role),
status: m.active ? 'Active' : 'Removed',
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
});
const pending = invitations.data ?? [];
return (
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
resource={team}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="staff"
title="No team members yet"
description="People appear here once they accept an invitation."
/>
}
>
{(members) => (
<VStack gap={4}>
<ResponsiveTable
data={members.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['role', 'status']}
columns={COLUMNS}
/>
{/* Says where accounts come from, because the button that used to
claim to create one has gone. Without this the screen reads as
missing a feature rather than pointing at the right place. */}
<Text size="xsm" color="secondary">
New members are added by invitation, and choose their own password
when they redeem it. Roles and access are managed on the platform.
</Text>
</VStack>
<VStack gap={5}>
<PanelCard
title="Team"
subtitle="Everybody who can sign in to this company - here, on the shop PC, and in the app."
actions={canManage ? <Button size="sm" label="Add member" onClick={() => setAdding(true)} /> : undefined}
resource={team}
loading={<SkeletonRows count={4} />}
empty={<EmptyPanel icon="staff" title="Nobody yet" description="Add the first member." />}
>
{(members) => (
<ResponsiveTable data={members.map(toRow)} idKey="id" primaryKey="name" summaryKeys={['role', 'status']} columns={columns} />
)}
</PanelCard>
{canManage && pending.length > 0 && (
<PanelCard title="Pending invitations" subtitle="Codes sent but not yet used." resource={invitations} loading={<SkeletonRows count={2} />}>
{(rows) => (
<VStack gap={2}>
{rows.map((inv) => (
<HStack key={inv.id} gap={3} hAlign="between" vAlign="center">
<VStack gap={0}>
<Text size="sm" weight="medium">{inv.full_name || inv.email}</Text>
<Text size="xsm" color="secondary">
{inv.email} · {roleLabel(inv.role)} · expires {new Date(inv.expires_at).toLocaleDateString()}
</Text>
</VStack>
<Button size="sm" variant="ghost" label="Revoke" onClick={() => void revoke(inv)} />
</HStack>
))}
</VStack>
)}
</PanelCard>
)}
</PanelCard>
{adding && (
<AddMemberDialog
callerRole={me}
onClose={() => setAdding(false)}
onDone={() => {
setAdding(false);
team.refetch();
invitations.refetch();
}}
/>
)}
{secret && (
<Dialog isOpen onOpenChange={(o) => (o ? undefined : setSecret(null))} purpose="info" width={480} aria-label={secret.title}>
<VStack gap={4} width="100%">
<DialogHeader title={secret.title} onOpenChange={(o) => (o ? undefined : setSecret(null))} />
<SecretOnce label={secret.label} value={secret.value} hint={secret.hint} onDone={() => setSecret(null)} />
</VStack>
</Dialog>
)}
</VStack>
);
}

View File

@@ -16,63 +16,11 @@ export interface SettingsSection {
* layout changing.
*/
export const SETTINGS_NAV: SettingsSection[] = [
{
label: 'Business',
href: '/settings',
icon: ICONS.business,
description: 'Company details, tax IDs, registered address and support.',
},
{
label: 'Profile',
href: '/settings/profile',
icon: ICONS.profile,
description: 'Merchant contact details, timezone and personal info.',
},
{
label: 'Team & Staff',
href: '/settings/team',
icon: ICONS.staff,
description: 'Staff directory, store assignments, roles and status.',
},
{
label: 'Stores',
href: '/settings/stores',
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.',
},
{label: 'Account', href: '/settings', icon: ICONS.profile, description: 'Who you are signed in as, and your devices.'},
{label: 'Team', href: '/settings/team', icon: ICONS.staff, description: 'Who can sign in, and as what.'},
];
/** 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

@@ -0,0 +1,18 @@
import {postJson, deleteJson} from '@/shared/services/httpClient';
import type {Endpoint} from '@/shared/services/httpClient';
export interface DeviceSession {
id: string;
device: string;
created_at: string;
last_used_at?: string;
expires_at: string;
current: boolean;
}
export const securityRepository = {
sessions: (): Endpoint<DeviceSession[]> => ({path: '/api/auth/sessions', params: {}}),
revoke: (id: string) => deleteJson<null>(`/api/auth/sessions/${encodeURIComponent(id)}`),
/** Keeps this device signed in. */
revokeOthers: () => postJson<null>('/api/auth/sessions/revoke-others', {}),
};

View File

@@ -1,16 +0,0 @@
import {getJson, patchJson} from '@/shared/services/httpClient';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Settings endpoints. See dashboardRepository for the layer's contract.
*
* Imperative rather than Endpoint-shaped: a settings record has one state at
* page load and changes only when the user saves it, so there is nothing for
* useResource's scope-keyed refetching to do.
*/
export const settingsRepository = {
profile: () => getJson<MerchantProfile>('/api/settings/profile'),
updateProfile: (patch: Partial<MerchantProfile>) =>
patchJson<MerchantProfile>('/api/settings/profile', patch),
};

View File

@@ -1,22 +0,0 @@
import 'server-only';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* The merchant profile.
*
* There is no business-profile resource in the platform contract — GET
* /api/auth/me returns the signed-in USER (name, email, role, tenant), not the
* company's GSTIN, timezone, currency or LYT accrual rate. The fixture that
* used to answer this has been removed, so this returns null and the form
* renders its unavailable state.
*
* When the resource ships, this function is the only thing that changes.
*
* Required: GET/PATCH /api/settings/profile — business name, GSTIN, timezone,
* currency, notification preferences.
*/
export const settingsServerRepository = {
async getProfile(): Promise<MerchantProfile | null> {
return null;
},
};

View File

@@ -1,55 +0,0 @@
import {settingsRepository} from '@/features/settings/repositories/settingsRepository';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Domain rules for the settings module.
*
* The validation lives here rather than in the form for the same reason the
* login rules live in authService: a rule that only exists inside a component
* cannot be reused by a second screen, cannot be unit-tested without mounting
* React, and quietly disappears the day the screen is redesigned.
*/
export type ProfileFieldErrors = Partial<Record<keyof MerchantProfile, string>>;
export function validateProfile(profile: MerchantProfile): ProfileFieldErrors {
const errors: ProfileFieldErrors = {};
if (!profile.businessName.trim()) {
errors.businessName = 'Business name is required';
}
if (!/^\S+@\S+\.\S+$/.test(profile.email)) {
errors.email = 'Enter a valid email address';
}
// Optional, but a partial GSTIN is worse than none — it will fail at
// settlement time rather than here.
if (profile.gstin && profile.gstin.length !== 15) {
errors.gstin = 'GSTIN is 15 characters';
}
return errors;
}
export const settingsService = {
validateProfile,
/**
* Validate, then save. Returns the field errors rather than throwing —
* invalid input is an expected outcome of a form submission, not an
* exception, and the caller has to render them either way.
*/
async saveProfile(
profile: MerchantProfile,
): Promise<{ok: true} | {ok: false; errors: ProfileFieldErrors; message?: string}> {
const errors = validateProfile(profile);
if (Object.keys(errors).length > 0) return {ok: false, errors};
const res = await settingsRepository.updateProfile(profile);
if (!res.ok) {
return {
ok: false,
errors: {},
message: res.message ?? 'Could not save profile',
};
}
return {ok: true};
},
};

View File

@@ -1,26 +0,0 @@
/**
* Merchant settings 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 interface MerchantProfile {
businessName: string;
contactName: string;
email: string;
phone: string;
gstin: string;
/** IANA zone — all reporting timestamps are rendered in it. */
timezone: string;
currency: 'INR';
/** Default LYTs granted per ₹100 spent. */
lytsPerHundred: number;
notifications: {
expiryAlerts: boolean;
dailySummary: boolean;
staffAbsence: boolean;
lowConversion: boolean;
};
}

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

@@ -0,0 +1,132 @@
'use client';
import {useState} from 'react';
import {Card} from '@astryxdesign/core/Card';
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 {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {useToast} from '@astryxdesign/core/Toast';
import {ICONS} from '@/shared/utils/icons';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import type {Camera} from '@/features/stores/types/camera';
/**
* A camera is a thing you look at, so the picture is the card. One line under
* it answers the question a site gets signed off without: has anyone proved
* this camera can recognise a face - which is different from "connected".
*/
export function CameraCard({camera, onEdit, onChanged}: {camera: Camera; onEdit: () => void; onChanged: () => void}) {
const toast = useToast();
const [busy, setBusy] = useState<string | null>(null);
async function check(kind: 'connection' | 'placement') {
setBusy(kind);
const res = await cameraRepository.check(camera.id, kind);
setBusy(null);
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not ask the shop PC.'});
else toast({body: kind === 'placement' ? 'Placement check requested - walk through the frame for the next 25 seconds.' : 'Connection test requested.'});
onChanged();
}
async function remove() {
if (!window.confirm(`Remove ${camera.label}? The shop PC stops watching it on its next sync.`)) return;
setBusy('remove');
const res = await cameraRepository.remove(camera.id);
setBusy(null);
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not remove this camera.'});
onChanged();
}
const conn = connection(camera);
const verified = verification(camera);
const checking = camera.check.state === 'requested' || camera.check.state === 'started';
return (
<Card>
<VStack gap={3}>
<div style={{position: 'relative', aspectRatio: '16 / 9', borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface-sunken, #111)'}}>
{camera.snapshot.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={camera.snapshot.url} alt={camera.label} style={{width: '100%', height: '100%', objectFit: 'cover', display: 'block'}} />
) : (
<div style={{position: 'absolute', inset: 0, display: 'grid', placeItems: 'center'}}>
<VStack gap={1} hAlign="center">
<Icon icon={ICONS.stores} size="lg" color="secondary" />
<Text size="xsm" color="secondary">{camera.snapshot.reason ?? 'No picture yet'}</Text>
</VStack>
</div>
)}
<div style={{position: 'absolute', top: 8, right: 8}}>
<Badge variant={conn.variant} label={conn.label} />
</div>
</div>
<HStack gap={2} hAlign="between" vAlign="center">
<VStack gap={0}>
<Heading level={4}>{camera.label}</Heading>
<Text size="xsm" color="secondary" className="font-mono">{camera.cameraId} · {camera.host}</Text>
</VStack>
<DropdownMenu
button={{label: 'Actions', variant: 'secondary', size: 'sm'}}
items={[
{label: 'Test connection', onClick: () => void check('connection'), isDisabled: Boolean(busy) || checking},
{label: 'Check placement (25 s walk-past)', onClick: () => void check('placement'), isDisabled: Boolean(busy) || checking},
{type: 'divider'},
{label: 'Edit', onClick: onEdit},
{label: 'Remove', onClick: () => void remove(), isDisabled: Boolean(busy)},
]}
/>
</HStack>
<HStack gap={2} vAlign="center">
<Badge variant={verified.variant} label={verified.label} />
<Text size="xsm" color="secondary">{verified.detail}</Text>
</HStack>
{camera.check.advice.length > 0 && !camera.check.ok && camera.check.state === 'finished' && (
<VStack gap={0.5}>
{camera.check.advice.slice(0, 2).map((a) => (
<Text key={a} size="xsm" color="secondary">· {a}</Text>
))}
</VStack>
)}
{camera.snapshotAt && (
<Text size="xsm" color="secondary">
Picture <Timestamp value={camera.snapshotAt} format="relative" />
</Text>
)}
{!camera.enabled && <Button size="sm" variant="secondary" label="Disabled" isDisabled />}
</VStack>
</Card>
);
}
/** Three states, not two. null is "no shop PC has reported on this yet" and
* must not read as "broken" - it sends an installer to check the cabling on
* a camera nobody has tried to reach. */
function connection(c: Camera): {variant: 'success' | 'warning' | 'error' | 'neutral'; label: string} {
if (c.connected === true) return {variant: 'success', label: 'Connected'};
if (c.connected === false) return {variant: 'error', label: 'Not connecting'};
return {variant: 'neutral', label: 'Waiting for the shop PC'};
}
function verification(c: Camera): {variant: 'success' | 'warning' | 'error' | 'neutral'; label: string; detail: string} {
const k = c.check;
if (k.state === 'requested') return {variant: 'neutral', label: 'Checking…', detail: 'waiting for the shop PC to pick it up'};
if (k.state === 'started') return {variant: 'neutral', label: 'Checking…', detail: k.kind === 'placement' ? 'walk through the frame now' : 'testing the connection'};
if (k.state === 'finished') {
if (k.kind === 'placement') {
// Only `good` is a pass. `marginal` means half the visitors are silently
// discarded, which is not a working camera.
if (k.verdict === 'good') return {variant: 'success', label: 'Proven', detail: k.headline ?? 'faces recognised on a walk-past'};
return {variant: k.verdict === 'marginal' ? 'warning' : 'error', label: 'Not proven', detail: k.headline ?? k.verdict ?? ''};
}
return k.ok
? {variant: 'success', label: 'Connection OK', detail: `${k.headline ?? 'stream opens'} · run a placement check to prove recognition`}
: {variant: 'error', label: 'Connection failed', detail: k.headline ?? ''};
}
return {variant: 'neutral', label: 'Not yet checked', detail: 'added, not proven - run a placement check'};
}

View File

@@ -0,0 +1,146 @@
'use client';
import {useEffect, 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 {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {Text} from '@astryxdesign/core/Text';
import {MAKES} from '@/features/stores/data/cameraMakes';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import type {Camera, CameraDraft} from '@/features/stores/types/camera';
interface Props {
site: string;
/** Editing when set; adding otherwise. */
camera?: Camera;
onClose: () => void;
onSaved: (camera: Camera) => void;
}
const DEFAULT_MAKE = 'hikvision';
const EMPTY: CameraDraft = {
cameraId: '', label: '', host: '', port: 554,
path: MAKES.find((m) => m.id === DEFAULT_MAKE)?.path ?? '',
username: '', password: '',
};
export function CameraDialog({site, camera, onClose, onSaved}: Props) {
const editing = Boolean(camera);
const [draft, setDraft] = useState<CameraDraft>(
camera
? {cameraId: camera.cameraId, label: camera.label, host: camera.host, port: camera.port, path: camera.path, username: camera.username, password: ''}
: EMPTY,
);
const [make, setMake] = useState(editing ? 'manual' : DEFAULT_MAKE);
// TextInput exposes no autocomplete prop, so the attribute is set on the
// real inputs after mount. Measured without it: Chrome filled the
// operator's own Loyaly email into "Camera username" - a text input next to
// a password input is a sign-in form as far as a browser is concerned.
const userRef = useRef<HTMLInputElement>(null);
const passRef = useRef<HTMLInputElement>(null);
useEffect(() => {
userRef.current?.setAttribute('autocomplete', 'off');
userRef.current?.setAttribute('data-lpignore', 'true');
passRef.current?.setAttribute('autocomplete', 'new-password');
}, []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof CameraDraft>(k: K, v: CameraDraft[K]) => setDraft((d) => ({...d, [k]: v}));
const chosen = MAKES.find((m) => m.id === make);
function pickMake(id: string) {
setMake(id);
const m = MAKES.find((x) => x.id === id);
if (m && m.path) set('path', m.path);
}
async function save() {
setSaving(true);
setError(null);
// Read the credential fields from the DOM, not only from state: a browser
// autofill can write into an input without React ever seeing a change
// event, and the first camera added through this form arrived with its
// password stored and its username empty.
const final: CameraDraft = {
...draft,
username: userRef.current?.value ?? draft.username,
password: passRef.current?.value ?? draft.password,
};
const res = editing && camera
? await cameraRepository.update(camera.id, {label: final.label, host: final.host, port: final.port, path: final.path, username: final.username, password: final.password})
: await cameraRepository.add(site, final);
setSaving(false);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not save this camera.');
return;
}
onSaved(res.data);
}
const close = (open: boolean) => (open ? undefined : onClose());
const canSave = draft.host.trim() && draft.path.trim() && (editing || draft.cameraId.trim());
return (
<Dialog isOpen onOpenChange={close} purpose="form" width={520} aria-label={editing ? 'Edit camera' : 'Add a camera'}>
<VStack gap={4} width="100%">
<DialogHeader
title={editing ? `Edit ${camera?.label}` : 'Add a camera'}
subtitle={editing ? undefined : 'The shop PC connects to it on its next sync.'}
onOpenChange={close}
/>
{error && <Banner status="error" title={error} />}
{!editing && (
<TextInput
label="Camera id"
description="Short, no spaces - it names this camera in every report and cannot be changed later."
value={draft.cameraId}
onChange={(v) => set('cameraId', v.replace(/[^A-Za-z0-9_-]/g, ''))}
placeholder="entrance"
isRequired
/>
)}
<TextInput label="Name" value={draft.label} onChange={(v) => set('label', v)} placeholder="Front door" isOptional />
<HStack gap={3}>
<TextInput label="Address" value={draft.host} onChange={(v) => set('host', v)} placeholder="192.168.1.20" isRequired width="70%" />
<TextInput label="Port" value={String(draft.port)} onChange={(v) => set('port', Number(v.replace(/\D/g, '')) || 0)} width="30%" />
</HStack>
<Selector label="Camera make" value={make} onChange={pickMake} options={MAKES.map((m) => ({value: m.id, label: m.label}))} />
<TextInput
label="Stream path"
description={chosen?.note || 'Filled in from the make. Change it if the camera\'s own app says otherwise.'}
value={draft.path}
onChange={(v) => set('path', v)}
placeholder="/Streaming/Channels/101"
isRequired
/>
<HStack gap={3}>
<TextInput ref={userRef} label="Camera username" htmlName="rtsp-account" value={draft.username} onChange={(v) => set('username', v)} placeholder="admin" isOptional />
<TextInput
label="Camera password"
ref={passRef}
type="password"
htmlName="rtsp-secret"
value={draft.password}
onChange={(v) => set('password', v)}
placeholder={editing && camera?.hasPassword ? '(unchanged)' : ''}
isOptional
/>
</HStack>
{editing && <Text size="xsm" color="secondary">Leave the password blank to keep the one already stored.</Text>}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button isDisabled={!canSave || saving} isLoading={saving} onClick={() => void save()} label={editing ? 'Save' : 'Add camera'} />
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import {useEffect, useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {Text, Heading} from '@astryxdesign/core/Text';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import type {EnrolmentCode} from '@/features/stores/types/site';
/**
* The code a new shop PC types to become THIS shop. Minted when the dialog
* opens, shown once, and it is a credential: it is redeemed for the shop's
* broker login, so it should travel to the shop by a channel of its own.
*/
export function EnrolmentCodeDialog({site, siteName, onClose}: {site: string; siteName: string; onClose: () => void}) {
const [code, setCode] = useState<EnrolmentCode | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let live = true;
void cameraRepository.enrolmentCode(site).then((res) => {
if (!live) return;
if (res.ok && res.data) setCode(res.data);
else setError(res.message ?? 'Could not create a code.');
});
return () => {
live = false;
};
}, [site]);
const close = (open: boolean) => (open ? undefined : onClose());
const expires = code ? new Date(code.expires_at).toLocaleString() : '';
return (
<Dialog isOpen onOpenChange={close} purpose="info" width={520} aria-label="Set up a shop PC">
<VStack gap={4} width="100%">
<DialogHeader title="Set up a shop PC" subtitle={siteName} onOpenChange={close} />
{error && <Banner status="error" title={error} />}
{!code && !error && <Text size="sm" color="secondary">Creating a code…</Text>}
{code && (
<>
<Text size="sm" color="secondary">
Install Behavision on the PC in this shop and type this code on its first screen. It works once and expires {expires}.
</Text>
<Heading level={2} className="font-mono" style={{letterSpacing: '0.08em'}}>{code.code}</Heading>
<Banner
status="warning"
title="Treat it like a password."
description="Whoever has this code can connect a PC as this shop. Send it to the installer directly, not in a group chat."
/>
<HStack gap={2} hAlign="end">
<Button
variant="secondary"
label={copied ? 'Copied' : 'Copy code'}
onClick={() => {
void navigator.clipboard?.writeText(code.code);
setCopied(true);
}}
/>
<Button onClick={onClose} label="Done" />
</HStack>
</>
)}
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text, Heading} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Button} from '@astryxdesign/core/Button';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {formatPct} from '@/shared/utils/format';
import {useCameras} from '@/features/stores/hooks/useCameras';
import {CameraCard} from './CameraCard';
import {CameraDialog} from './CameraDialog';
import {EnrolmentCodeDialog} from './EnrolmentCodeDialog';
import type {Site} from '@/features/stores/types/site';
import type {Camera} from '@/features/stores/types/camera';
/** One shop: its health in a line, then its cameras as pictures. */
export function ShopSection({site, canManage}: {site: Site; canManage: boolean}) {
const cameras = useCameras(site.id);
const [dialog, setDialog] = useState<null | {kind: 'add'} | {kind: 'edit'; camera: Camera} | {kind: 'code'}>(null);
const health = verdictFor(site);
return (
<VStack gap={4}>
<HStack gap={3} hAlign="between" vAlign="start" wrap="wrap">
<VStack gap={1}>
<HStack gap={2} vAlign="center">
<Heading level={2}>{site.name}</Heading>
<StatusDot variant={health.tone} label={health.label} />
<Text size="sm" color="secondary">{health.label}</Text>
</HStack>
<Text size="sm" color="secondary">{health.line}</Text>
<StatRow>
<StatPair label="Cameras up" value={`${site.camerasUp}/${site.camerasTotal}`} />
<StatPair label="Faces too poor to use" value={site.camerasTotal ? formatPct(site.fractionBelowGate * 100, 0) : '—'} />
<StatPair label="Last heartbeat" value={site.lastHeartbeatAt ? <Timestamp value={site.lastHeartbeatAt} format="relative" /> : 'never'} />
{site.dropped > 0 && <StatPair label="Visits lost" value={String(site.dropped)} />}
</StatRow>
</VStack>
{canManage && (
<HStack gap={2}>
<Button variant="secondary" label="Set up a shop PC" onClick={() => setDialog({kind: 'code'})} />
<Button label="Add camera" onClick={() => setDialog({kind: 'add'})} />
</HStack>
)}
</HStack>
<AsyncBoundary
resource={cameras}
loading={<SkeletonCardGrid count={2} height={260} />}
empty={
<EmptyPanel
icon="stores"
title="No cameras yet"
description={canManage ? 'Add the camera by its address; the shop PC connects to it on its next sync.' : 'A manager can add cameras to this shop.'}
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
{rows.map((cam) => (
<CameraCard key={cam.id} camera={cam} onEdit={() => setDialog({kind: 'edit', camera: cam})} onChanged={cameras.refetch} />
))}
</Grid>
)}
</AsyncBoundary>
{dialog?.kind === 'add' && (
<CameraDialog site={site.id} onClose={() => setDialog(null)} onSaved={() => { setDialog(null); cameras.refetch(); }} />
)}
{dialog?.kind === 'edit' && (
<CameraDialog site={site.id} camera={dialog.camera} onClose={() => setDialog(null)} onSaved={() => { setDialog(null); cameras.refetch(); }} />
)}
{dialog?.kind === 'code' && <EnrolmentCodeDialog site={site.id} siteName={site.name} onClose={() => setDialog(null)} />}
</VStack>
);
}
/**
* One function decides a shop's health, and everything on the section reads
* from it - the dot, the label and the sentence. Severity order matters and
* only the first applies: a shop that is offline AND has a bad camera needs
* its PC turned on first.
*/
function verdictFor(s: Site): {tone: 'success' | 'warning' | 'error' | 'neutral'; label: string; line: string} {
if (s.camerasTotal === 0) return {tone: 'neutral', label: 'Not set up', line: 'No cameras yet. Add one, then set up the shop PC.'};
if (!s.isOnline) return {tone: 'error', label: 'Offline', line: s.lastHeartbeatAt ? 'The shop PC has stopped reporting. Check it is on and connected.' : 'No shop PC has connected yet. Use "Set up a shop PC".'};
if (s.dropped > 0) return {tone: 'error', label: 'Losing visits', line: `${s.dropped} visits were lost while this PC was offline too long.`};
if (s.camerasUp === 0) return {tone: 'error', label: 'No camera connected', line: 'The PC is on but cannot reach any camera.'};
if (s.camerasUp < s.camerasTotal) return {tone: 'warning', label: 'Camera trouble', line: `${s.camerasTotal - s.camerasUp} of ${s.camerasTotal} cameras not connecting.`};
if (s.fractionBelowGate > 0.5) return {tone: 'error', label: 'Faces too poor', line: 'Most faces this shop sees are too poor to recognise - the camera needs moving to head height, facing the approach.'};
if (s.fractionBelowGate > 0.2) return {tone: 'warning', label: 'Marginal', line: 'A fair share of faces are too poor to recognise. Consider re-aiming the camera.'};
return {tone: 'success', label: 'Working', line: 'Online, cameras connected, recognition running.'};
}

View File

@@ -0,0 +1,28 @@
/**
* RTSP paths by make. The address is on a label and the password is in the
* installer's notes; the PATH is model-specific and written nowhere a shop
* owner would look, and getting it wrong produces "could not open stream",
* which reads like a password problem and is not. Conventions, not
* guarantees - the field stays editable.
*
* Mirrors Behavision's shared/cameraMakes.js. Keep the two in step.
*/
export interface CameraMake {
id: string;
label: string;
path: string;
note: string;
}
export const MAKES: CameraMake[] = [
{id: 'hikvision', label: 'Hikvision', path: '/Streaming/Channels/101', note: 'Channel 1, main stream. /Streaming/Channels/102 is the lower-quality sub stream.'},
{id: 'dahua', label: 'Dahua', path: '/cam/realmonitor?channel=1&subtype=0', note: 'Channel 1, main stream. subtype=1 is the sub stream.'},
{id: 'cpplus', label: 'CP Plus', path: '/cam/realmonitor?channel=1&subtype=0', note: 'CP Plus cameras use the Dahua stream path.'},
{id: 'uniview', label: 'Uniview', path: '/media/video1', note: 'Some older Uniview models use /video1 instead.'},
{id: 'tplink', label: 'TP-Link / Tapo', path: '/stream1', note: 'Tapo cameras need a separate camera account created in the Tapo app - the Tapo login will not work.'},
{id: 'reolink', label: 'Reolink', path: '/h264Preview_01_main', note: '/h264Preview_01_sub is the lower-quality stream.'},
{id: 'amcrest', label: 'Amcrest', path: '/cam/realmonitor?channel=1&subtype=0', note: 'Amcrest cameras use the Dahua stream path.'},
{id: 'axis', label: 'Axis', path: '/axis-media/media.amp', note: ''},
{id: 'onvif', label: 'Other (ONVIF)', path: '/onvif1', note: 'Many generic cameras answer here. If not, look for "RTSP" in the camera\'s own app.'},
{id: 'manual', label: 'I know the path', path: '', note: ''},
];

View File

@@ -0,0 +1,23 @@
import {useEffect} from 'react';
import {useResource} from '@/shared/hooks/useResource';
import type {Resource} from '@/shared/hooks/useResource';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import type {Camera} from '@/features/stores/types/camera';
/**
* One shop's cameras, re-read every few seconds while the screen is open.
*
* Polling rather than on-demand because the interesting changes happen on the
* shop PC, not here: a check the operator asked for finishes there, a camera
* reconnects there, and the only way this screen learns is by asking again.
*/
export function useCameras(site: string | null, intervalMs = 8000): Resource<Camera[]> {
const res = useResource(site ? cameraRepository.list(site) : null);
const {refetch, status} = res;
useEffect(() => {
if (!site || status === 'loading') return;
const id = setInterval(refetch, intervalMs);
return () => clearInterval(id);
}, [site, status, refetch, intervalMs]);
return res;
}

View File

@@ -1,28 +0,0 @@
'use client';
import {storeRepository} from '@/features/stores/repositories/storeRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
/**
* Store data access. See features/dashboard/hooks/useDashboard for the pattern.
*/
/**
* The directory always asks for ALL stores regardless of the workspace filter:
* a page whose job is to compare locations cannot be filtered down to one, or
* it has nothing to compare. The period still applies.
*/
export function useStoreList() {
const {range} = useScope();
return useResource(storeRepository.list({range, storeId: 'all'}));
}
export function useStore(storeId: string) {
const {range} = useScope();
return useResource(storeRepository.byId({range, storeId}, storeId), {
// A single object is never "empty" — without this, useResource's default
// array check would call a populated store empty.
isEmpty: () => false,
});
}

View File

@@ -0,0 +1,33 @@
import {postJson, patchJson, deleteJson} from '@/shared/services/httpClient';
import type {Endpoint} from '@/shared/services/httpClient';
import type {Camera, CameraDraft} from '@/features/stores/types/camera';
import type {EnrolmentCode} from '@/features/stores/types/site';
export const cameraRepository = {
list: (site: string): Endpoint<Camera[]> => ({path: '/api/cameras', params: {site}}),
add: (site: string, draft: CameraDraft) => postJson<Camera>('/api/cameras', {site, ...toInput(draft)}),
/** Only what changed. A blank password is never sent: blank means "keep". */
update: (id: string, draft: Partial<CameraDraft>) => patchJson<Camera>(`/api/cameras/${encodeURIComponent(id)}`, toInput(draft)),
remove: (id: string) => deleteJson<null>(`/api/cameras/${encodeURIComponent(id)}`),
check: (id: string, kind: 'connection' | 'placement') =>
postJson<Camera>(`/api/cameras/${encodeURIComponent(id)}/check`, {kind}),
enrolmentCode: (site: string, label?: string) =>
postJson<EnrolmentCode>(`/api/sites/${encodeURIComponent(site)}/enrolment-code`, label ? {label} : {}),
};
function toInput(d: Partial<CameraDraft>) {
const out: Record<string, string | number> = {};
if (d.cameraId !== undefined && d.cameraId !== '') out.camera_id = d.cameraId.trim();
if (d.label !== undefined) out.label = d.label.trim();
if (d.host !== undefined && d.host !== '') out.host = d.host.trim();
if (d.port !== undefined && d.port > 0) out.port = d.port;
if (d.path !== undefined && d.path !== '') out.path = d.path.trim();
if (d.username !== undefined) out.username = d.username.trim();
if (d.password) out.password = d.password;
return out;
}

View File

@@ -1,11 +0,0 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {Store} from '@/features/stores/types/store';
/** Store endpoints. See dashboardRepository for the layer's contract. */
export const storeRepository = {
list: (scope: Scope): Endpoint<Store[]> => scopedEndpoint('/api/stores', scope),
byId: (scope: Scope, storeId: string): Endpoint<Store> =>
scopedEndpoint(`/api/stores/${storeId}`, scope),
};

View File

@@ -0,0 +1,39 @@
import type {ApiCamera, ApiImage} from '@/services/api/types';
import type {Camera, Picture} from '@/features/stores/types/camera';
/** A picture URL the browser can load: presigned links as they are,
* session-authenticated ones through this origin's image proxy. */
export function toPicture(img: ApiImage | undefined): Picture {
if (!img?.available || !img.url) return {url: null, reason: img?.reason ?? null};
const needsSession = img.auth || img.url.startsWith('/');
return {url: needsSession ? `/api/images?src=${encodeURIComponent(img.url)}` : img.url, reason: null};
}
export function toCamera(c: ApiCamera): Camera {
return {
id: c.id,
site: c.site ?? c.site_id,
cameraId: c.camera_id,
label: c.label || c.camera_id,
host: c.host,
port: c.port,
path: c.path,
username: c.username,
hasPassword: c.has_password,
enabled: c.enabled,
// Three states, not two: null is "no shop PC has reported on this yet".
connected: c.connected ?? null,
lastSeenAt: c.last_seen_at ?? null,
snapshot: toPicture(c.snapshot),
snapshotAt: c.snapshot_at ?? null,
check: {
kind: c.check?.kind ?? null,
state: c.check?.state ?? null,
ok: c.check?.ok ?? false,
verdict: c.check?.verdict ?? null,
headline: c.check?.headline ?? null,
advice: c.check?.advice ?? [],
finishedAt: c.check?.finished_at ?? null,
},
};
}

View File

@@ -0,0 +1,43 @@
export interface Picture {
url: string | null;
/** Why there is no picture, in the platform's words. */
reason: string | null;
}
export interface CameraCheck {
kind: string | null;
state: string | null;
ok: boolean;
verdict: string | null;
headline: string | null;
advice: string[];
finishedAt: string | null;
}
export interface Camera {
id: string;
site: string;
cameraId: string;
label: string;
host: string;
port: number;
path: string;
username: string;
hasPassword: boolean;
enabled: boolean;
connected: boolean | null;
lastSeenAt: string | null;
snapshot: Picture;
snapshotAt: string | null;
check: CameraCheck;
}
export interface CameraDraft {
cameraId: string;
label: string;
host: string;
port: number;
path: string;
username: string;
password: string;
}

View File

@@ -1,22 +1,24 @@
/**
* A shop, as the console consumes it.
*
* `id` is the platform's SLUG, not its uuid, and that is deliberate: the slug
* is immutable, readable, and is what every scoped request sends as `?site=`.
* The uuid is kept alongside for routes that prefer an opaque key, but nothing
* should key on `name` — display names are expected to change.
*
* Health fields are nullable rather than zero-defaulted. A deployment that
* does not report camera health is not a deployment with zero cameras up, and
* rendering "0/0 cameras" for "not reported" is how a working estate looks
* broken.
*/
export interface Site {
/** The slug: immutable, what every scoped request sends as `site=`. */
id: string;
uuid: string;
name: string;
isOnline: boolean | null;
camerasTotal: number | null;
camerasUp: number | null;
fractionBelowGate: number | null;
timezone: string;
isOnline: boolean;
lastHeartbeatAt: string | null;
lastEventAt: string | null;
recognitionModel: string | null;
camerasTotal: number;
camerasUp: number;
fractionBelowGate: number;
queued: number;
dropped: number;
}
export interface EnrolmentCode {
code: string;
site_id: string;
site_name: string;
label?: string;
expires_at: string;
}

View File

@@ -0,0 +1,112 @@
'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 {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {Text} from '@astryxdesign/core/Text';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import type {UserRole} from '@/features/auth/types/auth';
type Mode = 'create' | 'invite';
/**
* Two ways to give somebody an account, and the difference is who handles
* the password. "Create" makes the account now and hands the manager a
* generated password to pass on - right when the person is standing there.
* "Invite" hands them a code and they choose their own password - better
* when they have their phone, because the manager never sees it.
*/
export function AddMemberDialog({callerRole, onClose, onDone}: {callerRole: UserRole; onClose: () => void; onDone: () => void}) {
const [mode, setMode] = useState<Mode>('create');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<UserRole>('staff');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [secret, setSecret] = useState<{label: string; value: string; hint: string} | null>(null);
// A manager cannot mint an owner: promoting somebody past yourself is an
// escalation. The server enforces it; the form simply does not offer it.
const roles: UserRole[] = callerRole === 'owner' ? ['staff', 'manager', 'owner'] : ['staff', 'manager'];
async function submit() {
setBusy(true);
setError(null);
if (mode === 'create') {
const res = await teamRepository.addMember({email: email.trim(), full_name: name.trim(), role});
setBusy(false);
if (!res.ok || !res.data) return setError(res.message ?? 'Could not create the account.');
setSecret({
label: `${res.data.email} can sign in now with this password:`,
value: res.data.password,
hint: 'Give it to them directly. They can change it from their own Security settings.',
});
} else {
const res = await teamRepository.invite({email: email.trim(), full_name: name.trim() || undefined, role});
setBusy(false);
if (!res.ok || !res.data?.code) return setError(res.message ?? 'Could not create the invitation.');
setSecret({
label: `Send ${res.data.email} this code. They open the app, enter it, and choose their own password.`,
value: res.data.code,
hint: `Whoever has the code gets a ${role} account for this company. It expires ${new Date(res.data.expires_at).toLocaleString()}.`,
});
}
}
const close = (open: boolean) => (open ? undefined : onClose());
const valid = /.+@.+\..+/.test(email) && (mode === 'invite' || name.trim().length > 0);
return (
<Dialog isOpen onOpenChange={close} purpose="form" width={480} aria-label="Add a team member">
<VStack gap={4} width="100%">
<DialogHeader title="Add a team member" onOpenChange={close} />
{secret ? (
<SecretOnce {...secret} onDone={onDone} />
) : (
<>
{error && <Banner status="error" title={error} />}
<Selector
label="How"
value={mode}
onChange={(v) => setMode(v as Mode)}
options={[
{value: 'create', label: 'Create the account now - I will hand them the password'},
{value: 'invite', label: 'Send an invitation - they choose their own password'},
]}
/>
<TextInput label="Email" type="email" value={email} onChange={setEmail} placeholder="name@shop.in" isRequired />
<TextInput label="Full name" value={name} onChange={setName} placeholder="Priya R" isRequired={mode === 'create'} isOptional={mode === 'invite'} />
<Selector label="Role" value={role} onChange={(v) => setRole(v as UserRole)} options={roles.map((r) => ({value: r, label: roleLabel(r)}))} />
<Text size="xsm" color="secondary">{roleHelp(role)}</Text>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button isDisabled={!valid || busy} isLoading={busy} onClick={() => void submit()} label={mode === 'create' ? 'Create account' : 'Create invitation'} />
</HStack>
</>
)}
</VStack>
</Dialog>
);
}
export function roleLabel(r: UserRole): string {
return {staff: 'Staff', manager: 'Manager', owner: 'Owner', admin: 'Admin'}[r];
}
function roleHelp(r: UserRole): string {
switch (r) {
case 'staff':
return 'Sees arrivals and customers on the shop floor. Cannot change cameras or the team.';
case 'manager':
return 'Everything staff can, plus cameras, shop PCs and the team.';
case 'owner':
return 'Full control of the company, including other owners.';
default:
return '';
}
}

View File

@@ -2,9 +2,13 @@
import {useResource} from '@/shared/hooks/useResource';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import type {TeamMember} from '@/features/team/types/team';
import type {TeamMember, Invitation} from '@/features/team/types/team';
import type {Resource} from '@/shared/hooks/useResource';
export function useTeam(): Resource<TeamMember[]> {
return useResource(teamRepository.list());
}
export function useInvitations(): Resource<Invitation[]> {
return useResource(teamRepository.invitations());
}

View File

@@ -1,7 +1,26 @@
import {postJson, patchJson, deleteJson} from '@/shared/services/httpClient';
import type {Endpoint} from '@/shared/services/httpClient';
import type {TeamMember} from '@/features/team/types/team';
import type {TeamMember, Invitation, NewMember} from '@/features/team/types/team';
import type {UserRole} from '@/features/auth/types/auth';
/** Console accounts. Unscoped — team membership is per company, not per site. */
export const teamRepository = {
list: (): Endpoint<TeamMember[]> => ({path: '/api/team', params: {}}),
/** Creates the account now. The password is in the response ONCE. */
addMember: (body: {email: string; full_name: string; role: UserRole; password?: string}) =>
postJson<NewMember>('/api/team/members', body),
update: (id: string, patch: {role?: UserRole; active?: boolean}) =>
patchJson<TeamMember>(`/api/team/${encodeURIComponent(id)}`, patch),
/** New password shown ONCE; that person is signed out of every device. */
resetPassword: (id: string) => postJson<{password: string}>(`/api/team/${encodeURIComponent(id)}/password`, {}),
invitations: (): Endpoint<Invitation[]> => ({path: '/api/team/invitations', params: {}}),
/** The code is in the response ONCE. */
invite: (body: {email: string; full_name?: string; role: UserRole}) => postJson<Invitation>('/api/team/invitations', body),
revokeInvitation: (id: string) => deleteJson<null>(`/api/team/invitations/${encodeURIComponent(id)}`),
};

View File

@@ -1,26 +1,31 @@
import type {UserRole} from '@/features/auth/types/auth';
/**
* A person with console access.
*
* Not a shop-floor employee. `active: false` means signed out immediately and
* unable to sign back in; reactivating restores the account but not their old
* sessions.
*
* `organisation` was removed: it mapped from `client_name`, which
* `GET /api/team` does not send, so it was `undefined` on every row. Every
* member of this list belongs to the caller's own company by definition — the
* backend scopes the query by the session's tenant — so the field said nothing
* even when it was populated.
*/
export interface TeamMember {
id: string;
name: string;
email: string;
role: UserRole;
/** false = signed out immediately and unable to sign back in. */
active: boolean;
/** Absent for somebody who has never signed in — that is data, not a gap. */
lastLoginAt: string | null;
createdAt: string;
}
/** The server's raw shape; the password field exists only on creation. */
export interface NewMember {
id: string;
email: string;
full_name: string;
role: UserRole;
password: string;
}
export interface Invitation {
id: string;
email: string;
full_name?: string;
role: UserRole;
invited_by?: string;
code?: string;
expires_at: string;
created_at: string;
}

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,29 +1,54 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiCamera, ApiSite} from './types';
import {upstreamRaw, upstreamRequest} from './apiClient';
import type {ApiCamera, ApiCameraInput, ApiEnrolmentCode, ApiSite} from './types';
/**
* Shops and cameras.
*
* `{id}` accepts a uuid or the site's slug, and the slug is IMMUTABLE — safe
* to store in a saved URL, a config file or a scheduled report. The display
* name is free to change and should be, so nothing keys on it.
*/
/** Shops and their cameras. A shop is addressed by slug everywhere. */
export const sitesApi = {
list: (accessToken: string) =>
upstreamRequest<ApiSite[]>({path: '/api/sites', accessToken}),
list: (accessToken: string) => upstreamRequest<ApiSite[]>({path: '/api/sites', accessToken}),
/** Five-step smoke test for one shop. */
check: (accessToken: string, site: string) =>
upstreamRequest<unknown>({
path: `/api/sites/${encodeURIComponent(site)}/check`,
upstreamRequest<unknown>({path: `/api/sites/${encodeURIComponent(site)}/check`, accessToken}),
/** The one-shot code a shop PC types to claim itself. Manager and above. */
enrolmentCode: (accessToken: string, site: string, body: {label?: string; ttl_hours?: number}) =>
upstreamRequest<ApiEnrolmentCode>({
path: `/api/sites/${encodeURIComponent(site)}/enrolment-code`,
method: 'POST',
body,
accessToken,
}),
cameras: (accessToken: string, site?: string) =>
upstreamRequest<ApiCamera[]>({
path: '/api/cameras',
query: {site},
upstreamRequest<ApiCamera[]>({path: '/api/cameras', query: {site}, accessToken}),
addCamera: (accessToken: string, site: string, body: ApiCameraInput) =>
upstreamRequest<ApiCamera>({
path: `/api/sites/${encodeURIComponent(site)}/cameras`,
method: 'POST',
body,
accessToken,
}),
updateCamera: (accessToken: string, id: string, body: ApiCameraInput) =>
upstreamRequest<ApiCamera>({
path: `/api/cameras/${encodeURIComponent(id)}`,
method: 'PATCH',
body,
accessToken,
}),
deleteCamera: (accessToken: string, id: string) =>
upstreamRequest<void>({path: `/api/cameras/${encodeURIComponent(id)}`, method: 'DELETE', accessToken}),
/** Ask the shop PC to test the connection or run a placement check. */
checkCamera: (accessToken: string, id: string, kind: 'connection' | 'placement') =>
upstreamRequest<ApiCamera>({
path: `/api/cameras/${encodeURIComponent(id)}/check`,
method: 'POST',
body: {kind},
accessToken,
}),
/** Raw bytes of an authenticated image (snapshot or face). */
image: (accessToken: string, path: string) => upstreamRaw({path, accessToken}),
};

View File

@@ -1,38 +1,43 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiRole, ApiTeamMember} from './types';
import type {ApiInvitation, ApiRole, ApiTeamMember} from './types';
/**
* The people with console accounts.
*
* NOT shop-floor rostering. This is who can sign in, at what privilege — there
* is no attendance, shift or performance data upstream, which is why the
* Leaderboard's attendance metrics are recorded as a backend gap rather than
* being derived from this.
*
* Deactivating signs that person out immediately and stops them signing back
* in; reactivating restores the account but not their old sessions. A change
* that would leave the company with no active owner is refused with 409
* `last_owner`.
*/
export const teamApi = {
list: (accessToken: string) =>
upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
list: (accessToken: string) => upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
update: (
accessToken: string,
id: string,
patch: {role?: ApiRole; active?: boolean},
) =>
// Returns the UPDATED member, not an ApiUser — the handler writes back the
// same TeamMember shape `list` returns. Mis-typed as ApiUser, which has no
// `active`, so a caller reading the result could not see the change it had
// just made. Nothing calls this yet; correcting it now keeps the file
// honest for whoever wires the role/deactivate controls.
update: (accessToken: string, id: string, patch: {role?: ApiRole; active?: boolean}) =>
upstreamRequest<ApiTeamMember>({
path: `/api/team/${encodeURIComponent(id)}`,
method: 'PATCH',
body: patch,
accessToken,
}),
/** Create an account directly. The password comes back ONCE. */
createMember: (accessToken: string, body: {email: string; full_name: string; role: ApiRole; password?: string}) =>
upstreamRequest<ApiTeamMember & {password: string}>({
path: '/api/team/members',
method: 'POST',
body,
accessToken,
}),
/** Reset a password and sign that person out everywhere, in one step. */
resetPassword: (accessToken: string, id: string, password?: string) =>
upstreamRequest<{password: string}>({
path: `/api/team/${encodeURIComponent(id)}/password`,
method: 'POST',
body: password ? {password} : {},
accessToken,
}),
invitations: (accessToken: string) =>
upstreamRequest<ApiInvitation[]>({path: '/api/team/invitations', accessToken}),
/** Invite somebody to choose their own password. The code comes back ONCE. */
invite: (accessToken: string, body: {email: string; full_name?: string; role: ApiRole; ttl_hours?: number}) =>
upstreamRequest<ApiInvitation>({path: '/api/team/invitations', method: 'POST', body, accessToken}),
revokeInvitation: (accessToken: string, id: string) =>
upstreamRequest<void>({path: `/api/team/invitations/${encodeURIComponent(id)}`, method: 'DELETE', accessToken}),
};

View File

@@ -80,6 +80,8 @@ export interface ApiDeviceSession {
export interface ApiInvitation {
id: string;
email: string;
full_name?: string;
invited_by?: string;
role: Exclude<ApiRole, 'admin'>;
/** Returned exactly ONCE at mint time and never recoverable — only a hash is
* stored. Absent on every subsequent read. */
@@ -101,32 +103,89 @@ export interface ApiInvitationPreview {
// ---------------------------------------------------------------------------
export interface ApiSite {
/**
* The uuid, named `site_id` on the wire — NOT `id`.
*
* This was declared as `id`, so it deserialised to undefined, `uuid` on the
* mapped Site was undefined, and every store card rendered with
* `key={undefined}`. React's duplicate-key warning was the only symptom;
* nothing threw, because an absent field is not an error on either side.
*/
site_id: string;
/** Immutable. Safe to persist in a URL or a saved report; the name is not. */
slug: string;
name: string;
timezone?: string;
online?: boolean;
cameras_total?: number;
cameras_up?: number;
/** Share of arrivals whose match confidence fell below the identity gate. */
fraction_below_gate?: number;
timezone: string;
online: boolean;
last_heartbeat_at?: string;
last_event_at?: string;
recognition_model?: string;
agent_version?: string;
cameras_up: number;
cameras_total: number;
fraction_below_gate: number;
queued: number;
dropped: number;
}
/** A photo, or the reason there is none. `auth` means the URL needs our
* session's bearer - it is one of ours, not a presigned bucket link. */
export interface ApiImage {
available: boolean;
url?: string;
expires_in?: number;
auth?: boolean;
reason?: string;
}
export interface ApiCameraCheck {
kind?: 'connection' | 'placement' | string;
state?: 'requested' | 'started' | 'finished' | string;
requested_at?: string;
finished_at?: string;
seconds?: number;
ok: boolean;
verdict?: string;
headline?: string;
advice?: string[];
detail?: Record<string, unknown>;
image: ApiImage;
}
export interface ApiCamera {
id: string;
site_id: string;
name?: string;
online?: boolean;
last_still_url?: string | null;
site?: string;
camera_id: string;
label: string;
host: string;
port: number;
path: string;
username: string;
has_password: boolean;
max_width: number;
tuning?: Record<string, unknown>;
enabled: boolean;
revision: number;
/** null: no shop PC has reported yet. false: not connecting. */
connected?: boolean | null;
last_seen_at?: string;
snapshot: ApiImage;
snapshot_at?: string;
check: ApiCameraCheck;
}
/** Every field optional: a PATCH sends only what changed, and a blank
* password means "leave it alone", never "clear it". */
export interface ApiCameraInput {
camera_id?: string;
label?: string;
host?: string;
port?: number;
path?: string;
username?: string;
password?: string;
max_width?: number;
enabled?: boolean;
}
export interface ApiEnrolmentCode {
code: string;
site_id: string;
site_name: string;
label?: string;
expires_at: string;
}
// ---------------------------------------------------------------------------
@@ -347,96 +406,7 @@ export interface ApiAssistantAnswer {
// Sales (the merchant application's domain — LOYALY.md)
// ---------------------------------------------------------------------------
/**
* One line of a sale. `intent` is the load-bearing field: an enquiry keeps the
* price it was quoted at and contributes NOTHING to the bill, which is why
* `billable_paise` ships separately rather than being re-derived per client.
*/
export interface ApiSaleLine {
product_id?: string;
product_name: string;
price_paise: number;
intent: 'purchased' | 'enquired';
billable_paise: number;
}
/** Money is integer PAISE on this path, never a float. */
export interface ApiSale {
id: string;
invoice_no?: string;
site_id: string;
site_slug?: string;
visit_id?: string;
visitor_id?: string;
visitor_ref?: string;
customer_label?: string;
staff_id?: string;
staff_name?: string;
terminal_id?: string;
total_paise: number;
currency: string;
status: string;
lines?: ApiSaleLine[];
/** Counts, so the history list need not carry every line of every sale. */
purchased_lines?: number;
enquiry_lines?: number;
client_created_at?: string;
server_created_at: string;
}
export interface ApiDashboardSummary {
date: string;
from: string;
to: string;
timezone: string;
total_revenue_paise: number;
currency: string;
sales_count: number;
enquiries_count: number;
active_customer_count: number;
visitors_count: number;
}
// ---------------------------------------------------------------------------
// The shop floor (LOYALY.md §6/§7/§20)
// ---------------------------------------------------------------------------
/**
* One person the shop is dealing with right now.
*
* `visitor_id` ABSENT is the load-bearing case: the cameras saw a face and
* matched nothing, and that is exactly when the app offers to name them.
* It must stay distinguishable from a known customer who has no name yet.
*/
export interface ApiFloorVisit {
visit_id: string;
site_id: string;
site_slug?: string;
detected_at: string;
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
visitor_id?: string;
visitor_ref?: string;
label?: string;
phone?: string;
previous_visits: number;
attended_by?: string;
attended_by_name?: string;
attended_by_me: boolean;
attended_at?: string;
image?: {available: boolean; url?: string | null; reason?: string};
}
export interface ApiCustomerCreated {
id: string;
ref: string;
label: string;
full_name?: string;
phone?: string;
}
/** §16 — "processed" for a new sale, "already_processed" for a replay. */
export interface ApiSaleResult {
status: 'processed' | 'already_processed';
sale_id: string;
sale?: ApiSale;
}

View File

@@ -0,0 +1,34 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Button} from '@astryxdesign/core/Button';
/**
* A password or a code the server will never show again. The copy button
* exists because retyping a generated secret is how it ends up wrong, and the
* warning exists because the thing is going to be pasted into a chat.
*/
export function SecretOnce({label, value, hint, onDone}: {label: string; value: string; hint: string; onDone: () => void}) {
const [copied, setCopied] = useState(false);
return (
<VStack gap={4}>
<Text size="sm" color="secondary">{label}</Text>
<Heading level={2} className="font-mono" style={{letterSpacing: '0.04em', wordBreak: 'break-all'}}>{value}</Heading>
<Banner status="warning" title="Shown once." description={hint} />
<HStack gap={2} hAlign="end">
<Button
variant="secondary"
label={copied ? 'Copied' : 'Copy'}
onClick={() => {
void navigator.clipboard?.writeText(value);
setCopied(true);
}}
/>
<Button onClick={onDone} label="Done" />
</HStack>
</VStack>
);
}

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

@@ -59,11 +59,12 @@ export function isBranch(row: AccountRowSpec): row is AccountBranch {
* it is grouped by what it is rather than stacked by what fit.
*/
export const ACCOUNT_ROOT_GROUPS: AccountRowSpec[][] = [
// Flat: every row is a destination that exists. The cascading panels below
// are unreachable from here and kept only until the menu is simplified.
[
{label: 'Settings', icon: ICONS.settings, panel: 'settings'},
{label: 'Profile', icon: ICONS.profile, panel: 'profile'},
{label: 'Language', icon: ICONS.language, panel: 'language'},
{label: 'Help', icon: ICONS.help, panel: 'help'},
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
{label: 'Team', icon: ICONS.staff, href: '/settings/team'},
{label: 'Keyboard shortcuts', icon: ICONS.keyboard, action: 'shortcuts'},
],
[
{
@@ -121,14 +122,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
profile: {
title: 'Profile',
rows: [
{label: 'Business Details', icon: ICONS.business, href: '/settings'},
{label: 'Subscription', icon: ICONS.billing, href: '/settings/billing'},
{label: 'Activity', icon: ICONS.analytics, href: '/activity'},
// Devices and Sessions are two views of the same Security screen today.
// They stay two rows because they are two questions a merchant asks;
// when Security splits into tabs, only the href moves.
{label: 'Devices', icon: ICONS.security, href: '/settings/security'},
{label: 'Sessions', icon: ICONS.sessions, href: '/settings/security'},
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
],
},
language: {
@@ -137,7 +131,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
{
label: 'Language settings',
icon: ICONS.preferences,
href: '/settings/preferences',
href: '/settings',
},
],
},

View File

@@ -14,11 +14,8 @@ 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: 'Live', href: '/activity', icon: ICONS.visitors},
{label: 'Stores', href: '/stores', icon: ICONS.stores},
];
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */

View File

@@ -127,3 +127,38 @@ export async function serveUpstream<U, T = U>(
return failResponse(err);
}
}
/**
* A write (or a read with no scope) proxied to the platform.
*
* The body is parsed once and handed to the caller; the platform's own
* validation answers a bad one, and its message is shown as-is. `status` is
* for a 201 on create. An upstream 204 comes back as `{data: null}` so the
* client sees one envelope shape everywhere.
*/
export async function proxyUpstream<U, T = U>(
req: NextRequest,
call: (accessToken: string, body: Record<string, unknown>, params: URLSearchParams) => Promise<U>,
opts?: {map?: (upstream: U) => T; status?: number},
): Promise<Response> {
let body: Record<string, unknown> = {};
if (req.method !== 'GET' && req.method !== 'DELETE') {
try {
const parsed: unknown = await req.json();
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
} catch {
// An empty body is legitimate for several endpoints (reset a password,
// mint a code). The platform rejects a body that is actually required.
}
}
try {
const upstream = await withUpstream((token) => call(token, body, req.nextUrl.searchParams));
const data = opts?.map ? opts.map(upstream) : upstream;
return Response.json(
{data: data ?? null, meta: {generatedAt: new Date().toISOString()}},
{status: opts?.status ?? 200, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
return failResponse(err);
}
}

View File

@@ -162,3 +162,7 @@ export function patchJson<T>(
body: JSON.stringify(body),
});
}
export function deleteJson<T>(path: string): Promise<HttpResult<T>> {
return request<T>(path, {method: 'DELETE'});
}