Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b61077e2f | |||
| 30d10921c2 |
83
.env
83
.env
@@ -1,79 +1,14 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production runtime configuration. COMMITTED ON PURPOSE — carries no secret.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime configuration. Committed on purpose - it carries no secret.
|
||||
# Precedence: process.env > .env.production.local > .env.local > .env.production > .env
|
||||
#
|
||||
# This file is the production environment. It is read by `next build` and, more
|
||||
# importantly, by the standalone `server.js` at boot (Next calls loadEnvConfig
|
||||
# on the server's working directory), so the deployed container knows the
|
||||
# platform host without anyone remembering to type it into a dashboard.
|
||||
#
|
||||
# ── Precedence, exactly as @next/env resolves it ────────────────────────────
|
||||
#
|
||||
# 1. real process.env (Dokploy / docker -e / systemd) ← always wins
|
||||
# 2. .env.production.local
|
||||
# 3. .env.local ← LOCAL DEV ONLY. Never enters the image.
|
||||
# 4. .env.production
|
||||
# 5. .env ← this file, the floor everything falls back to
|
||||
#
|
||||
# A value already present in process.env is never overwritten by a file, so
|
||||
# setting LOYALY_API_BASE in Dokploy still overrides this — nothing here locks
|
||||
# the deployment in. It only removes "unset" as a possible state.
|
||||
#
|
||||
# ── Working on this locally? ────────────────────────────────────────────────
|
||||
# Put your overrides in `.env.local` (gitignored, loaded ahead of this file).
|
||||
# Without one, `npm run dev` will talk to the PRODUCTION platform, because that
|
||||
# is what this file says. `.env.example` has the local values to copy.
|
||||
|
||||
# The one shared Loyaly platform API (Behavision). Server-side only and
|
||||
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
|
||||
# the BFF, which is what keeps the access token out of JavaScript.
|
||||
#
|
||||
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
|
||||
# the variable there makes the BFF call its own origin, which fails in a way
|
||||
# that looks like a broken login form rather than a misconfiguration.
|
||||
# apiClient.ts rejects that hostname by name for exactly this reason.
|
||||
#
|
||||
# NOT REQUIRED in production any more. Production accepts exactly one origin, so
|
||||
# an unset variable could never have meant another one, and platformApi resolves
|
||||
# it to that origin on its own. It stays here so `docker run` is self-describing
|
||||
# and so development has something to read.
|
||||
#
|
||||
# Why that change was needed: @next/env only fills a variable that is ABSENT.
|
||||
# Verified against the installed copy — a real environment variable set to the
|
||||
# EMPTY STRING stays empty and this file is NOT consulted. So one blank field in
|
||||
# a dashboard silently defeated the value below and took production down with
|
||||
# "LOYALY_API_BASE is required in production".
|
||||
# The platform API. https://mcp.loyaly.ai is the default in every environment
|
||||
# and the only value production accepts; it is written here so `docker run`
|
||||
# is self-describing. NOT platform.loyaly.ai - that host serves this console.
|
||||
LOYALY_API_BASE=https://mcp.loyaly.ai
|
||||
|
||||
# Browser → this app's own BFF routes, which are same-origin. Empty is correct
|
||||
# and is what makes the console work on any hostname it is served from:
|
||||
# requests go to /api/... on whatever origin loaded the page (localhost:3100 in
|
||||
# dev, platform.loyaly.ai in production) and the server hop above reaches the
|
||||
# platform. Setting this to the platform host would send the browser straight
|
||||
# at the API with no session cookie and no token — do not.
|
||||
#
|
||||
# It is NEXT_PUBLIC, so it is inlined at BUILD time, not read at runtime.
|
||||
# Changing it in Dokploy's environment panel would do nothing without a rebuild.
|
||||
# Browser -> this app's own routes, same origin. Empty is correct.
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
|
||||
# AUTH_SECRET is deliberately NOT in this file. It is the ONLY variable this
|
||||
# deployment requires, and the only one that cannot ship.
|
||||
#
|
||||
# It signs the session cookie and encrypts the platform token bundle, so a
|
||||
# value committed here is a session-forging key in git — anyone who can read
|
||||
# the repo could mint a cookie for any user. It was already removed from the
|
||||
# Dockerfile once for that reason; do not reintroduce it here.
|
||||
#
|
||||
# Set it as a Dokploy environment variable in the RUNTIME panel — a value set as
|
||||
# a BUILD argument is not present when the server runs, which looks exactly like
|
||||
# never having set it. Alternatively mount the value and set AUTH_SECRET_FILE to
|
||||
# its path (the Docker/Swarm secret convention); AUTH_SECRET wins if both exist.
|
||||
#
|
||||
# Production refuses to sign sessions without it. Generate with:
|
||||
#
|
||||
# openssl rand -hex 32
|
||||
#
|
||||
# Hex, not base64: a base64 value ends in '=' and can contain '+' and '/', and
|
||||
# an environment editor that splits a line on the first '=' can store that
|
||||
# truncated or empty. A silently-empty AUTH_SECRET looks exactly like an unset
|
||||
# one, which is a slow afternoon. Hex has nothing a parser can mangle.
|
||||
# AUTH_SECRET is deliberately NOT here: it signs sessions, so a committed value
|
||||
# is a session-forging key in git. Set it in the runtime environment (or point
|
||||
# AUTH_SECRET_FILE at a mounted secret). Generate with: openssl rand -hex 32
|
||||
|
||||
46
.env.example
46
.env.example
@@ -1,42 +1,12 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template for `.env.local` — your LOCAL overrides. Copy it:
|
||||
#
|
||||
# cp .env.example .env.local
|
||||
#
|
||||
# Do not copy it to `.env`. `.env` is committed and already holds the
|
||||
# production values; `.env.local` is loaded ahead of it and is gitignored.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Copy to .env.local for development. Only AUTH_SECRET is required.
|
||||
|
||||
# The one shared Loyaly platform API (Behavision). Server-side only and
|
||||
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
|
||||
# the BFF, which is what keeps the access token out of JavaScript.
|
||||
#
|
||||
# local dev http://127.0.0.1:8088 ← what belongs in .env.local
|
||||
# production https://mcp.loyaly.ai ← already set in the committed .env
|
||||
#
|
||||
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
|
||||
# the variable there makes the BFF call its own origin, which fails in a way
|
||||
# that looks like a broken login form rather than a misconfiguration.
|
||||
#
|
||||
# Production no longer requires this: it accepts exactly one origin, so an unset
|
||||
# value can only have meant that one, and platformApi resolves it. Any OTHER
|
||||
# host set explicitly is still rejected. Locally it is worth setting, because a
|
||||
# dev machine legitimately means a different address.
|
||||
LOYALY_API_BASE=http://127.0.0.1:8088
|
||||
|
||||
# Signs the session cookie and encrypts the platform token bundle.
|
||||
#
|
||||
# The ONLY variable production requires, the only real secret, and the only one
|
||||
# taken solely from the environment — it is in no committed file, by design.
|
||||
# Set it as a Dokploy environment variable in the RUNTIME panel (a build
|
||||
# argument is not present at runtime), or mount it and set AUTH_SECRET_FILE to
|
||||
# its path. Locally, any string works; leave it blank and a development key is
|
||||
# used.
|
||||
#
|
||||
# Generate with: openssl rand -hex 32 (hex, not base64 — a trailing '=' can be
|
||||
# mangled by a dashboard env editor that splits on the first '=')
|
||||
# Signs the session cookie and encrypts the platform tokens inside it.
|
||||
# Generate with: openssl rand -hex 32
|
||||
AUTH_SECRET=
|
||||
|
||||
# Browser → this app's own BFF routes. Same origin, so leave it empty. Inlined
|
||||
# at BUILD time (NEXT_PUBLIC), so changing it at runtime does nothing.
|
||||
# The platform API. Defaults to https://mcp.loyaly.ai in EVERY environment;
|
||||
# set this only if you are deliberately developing against another backend.
|
||||
# LOYALY_API_BASE=
|
||||
|
||||
# Browser -> this app's own routes. Empty is correct.
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
10
src/app/api/auth/sessions/[id]/route.ts
Normal file
10
src/app/api/auth/sessions/[id]/route.ts
Normal 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));
|
||||
}
|
||||
10
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
10
src/app/api/auth/sessions/revoke-others/route.ts
Normal 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));
|
||||
}
|
||||
10
src/app/api/auth/sessions/route.ts
Normal file
10
src/app/api/auth/sessions/route.ts
Normal 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));
|
||||
}
|
||||
17
src/app/api/cameras/[id]/check/route.ts
Normal file
17
src/app/api/cameras/[id]/check/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
21
src/app/api/cameras/[id]/route.ts
Normal file
21
src/app/api/cameras/[id]/route.ts
Normal 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));
|
||||
}
|
||||
26
src/app/api/cameras/route.ts
Normal file
26
src/app/api/cameras/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
38
src/app/api/images/route.ts
Normal file
38
src/app/api/images/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
19
src/app/api/sites/[site]/enrolment-code/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
13
src/app/api/team/[id]/password/route.ts
Normal file
13
src/app/api/team/[id]/password/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
18
src/app/api/team/[id]/route.ts
Normal file
18
src/app/api/team/[id]/route.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
10
src/app/api/team/invitations/[id]/route.ts
Normal file
10
src/app/api/team/invitations/[id]/route.ts
Normal 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));
|
||||
}
|
||||
25
src/app/api/team/invitations/route.ts
Normal file
25
src/app/api/team/invitations/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
22
src/app/api/team/members/route.ts
Normal file
22
src/app/api/team/members/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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, {}),
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)};
|
||||
}
|
||||
@@ -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, {}),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
31
src/features/settings/components/AccountCard.tsx
Normal file
31
src/features/settings/components/AccountCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
18
src/features/settings/repositories/securityRepository.ts
Normal file
18
src/features/settings/repositories/securityRepository.ts
Normal 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', {}),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
132
src/features/stores/components/CameraCard.tsx
Normal file
132
src/features/stores/components/CameraCard.tsx
Normal 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'};
|
||||
}
|
||||
146
src/features/stores/components/CameraDialog.tsx
Normal file
146
src/features/stores/components/CameraDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal file
70
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/features/stores/components/ShopSection.tsx
Normal file
100
src/features/stores/components/ShopSection.tsx
Normal 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.'};
|
||||
}
|
||||
28
src/features/stores/data/cameraMakes.ts
Normal file
28
src/features/stores/data/cameraMakes.ts
Normal 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: ''},
|
||||
];
|
||||
23
src/features/stores/hooks/useCameras.ts
Normal file
23
src/features/stores/hooks/useCameras.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
33
src/features/stores/repositories/cameraRepository.ts
Normal file
33
src/features/stores/repositories/cameraRepository.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
39
src/features/stores/services/mapCamera.ts
Normal file
39
src/features/stores/services/mapCamera.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
43
src/features/stores/types/camera.ts
Normal file
43
src/features/stores/types/camera.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
112
src/features/team/components/AddMemberDialog.tsx
Normal file
112
src/features/team/components/AddMemberDialog.tsx
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)}`),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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}),
|
||||
};
|
||||
@@ -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}),
|
||||
};
|
||||
|
||||
@@ -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}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
34
src/shared/components/patterns/SecretOnce.tsx
Normal file
34
src/shared/components/patterns/SecretOnce.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user