update ui changes
This commit is contained in:
@@ -1,175 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
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 {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
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 {formatBucketLabel, formatInrCompact} from '@/shared/utils/format';
|
||||
// TEMPORARY sample data — removal steps in shared/mocks/withSample.ts.
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_CONVERSION_DAILY, hasSignal} from '@/features/dashboard/mocks/dashboardMock';
|
||||
import {
|
||||
MOCK_PAYMENT_METHODS,
|
||||
MOCK_SALES,
|
||||
MOCK_TOP_PRODUCTS,
|
||||
} from '@/features/commerce/mocks/commerceMock';
|
||||
SkeletonChart,
|
||||
SkeletonMetricGrid,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useConversionReport} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {useSales, useTodaySales} from '@/features/commerce/hooks/useSales';
|
||||
import {SalesTrendChart} from '@/features/commerce/components/SalesTrendChart';
|
||||
import {
|
||||
PaymentMethodsPanel,
|
||||
TopOutletsPanel,
|
||||
TopProductsPanel,
|
||||
} from '@/features/commerce/components/CommercePanels';
|
||||
import {buildSampleSales} from '@/shared/mocks/sampleData';
|
||||
import {sampleNowMs, withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {shopToday} from '@/services/api/range';
|
||||
import {
|
||||
salesFacts,
|
||||
shopDay,
|
||||
salesTrend,
|
||||
summarise,
|
||||
totalPaise,
|
||||
} from '@/features/commerce/services/salesAnalysis';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {
|
||||
formatCompact,
|
||||
formatCount,
|
||||
formatInr,
|
||||
formatInrCompact,
|
||||
formatPct,
|
||||
} from '@/shared/utils/format';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
const formatPct = (v: number) => `${v}%`;
|
||||
const noSales = (
|
||||
<EmptyPanel
|
||||
icon="commerce"
|
||||
title="No sales in this period"
|
||||
description="Sales will appear here when staff record them in the merchant app."
|
||||
/>
|
||||
);
|
||||
|
||||
// Money arrives in paise; the cards show compact rupees ("₹1.24L") and whole
|
||||
// rupees for the average and today's line ("₹667") — headlines, not a ledger.
|
||||
const salesFormat = (paise: number) => formatInrCompact(paise / 100);
|
||||
const rupeesFormat = (paise: number) => formatInr(paise / 100);
|
||||
|
||||
/**
|
||||
* Sales.
|
||||
* Commerce — the merchant app's layout, on the platform's real 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.
|
||||
* One source for the page: the sale list for the selected store and period
|
||||
* (GET /api/sales, every page of it). The four cards, the trend charts and the
|
||||
* outlet ranking are all derived from those rows — see salesAnalysis.ts — so a
|
||||
* filter change moves all of them together and they cannot disagree. The one
|
||||
* extra request is today's sales, for the Sales card's "Today" line, since a
|
||||
* custom period need not include today.
|
||||
*
|
||||
* The platform reports revenue and basket size through the conversion report,
|
||||
* and nothing else on this page. Until a shop has history — and until the
|
||||
* catalogue and payment resources exist — panels show clearly-tagged SAMPLE
|
||||
* data (features/commerce/mocks) so the page explains itself. Real data
|
||||
* replaces it automatically; removal steps are in shared/mocks/withSample.ts.
|
||||
* The cards carry no percentage badge: the sale list has no previous period to
|
||||
* compare against, and a change figure with nothing behind it would be
|
||||
* invented. Their sparklines are the same rows, per day of the period.
|
||||
*
|
||||
* REAL: revenue, orders, average order, customers, enquiries, the revenue
|
||||
* and order trends, and top outlets (all from
|
||||
* the sale list), and conversion (the platform's conversion report).
|
||||
* SAMPLE, and tagged as such: net sales and refunds, payment methods and top
|
||||
* products — the sale list carries no refund, discount, payment method or
|
||||
* product line. Their values live in shared/mocks/sampleData.ts.
|
||||
*/
|
||||
export default function CommercePage() {
|
||||
const [openSale, setOpenSale] = useState<string | null>(null);
|
||||
const conversion = withSample(
|
||||
useConversionReport({bucket: 'day'}),
|
||||
MOCK_CONVERSION_DAILY,
|
||||
(d) => hasSignal(d, 'revenue'),
|
||||
);
|
||||
const sales = withSample(useSales(), MOCK_SALES, (d) => !!d?.length);
|
||||
const realSales = useSales();
|
||||
const {range, custom, storeId} = useScope();
|
||||
const {stores} = useWorkspace();
|
||||
const isTodayRange = range === 'today';
|
||||
const today = useTodaySales(!isTodayRange);
|
||||
const scopeLabel = useScopeLabel();
|
||||
const conversion = useConversionReport({bucket: 'day'});
|
||||
|
||||
// The server's clock, never the device's — "today" is the shop's today.
|
||||
const serverNow =
|
||||
realSales.meta?.generatedAt ?? new Date(sampleNowMs()).toISOString();
|
||||
|
||||
// SAMPLE — stands in only when the platform returned no sales for the
|
||||
// window, so a new shop sees the page populated. Spread across the stores
|
||||
// in scope so Top performing outlets shows the merchant's own shop names.
|
||||
const siteIds = useMemo(
|
||||
() =>
|
||||
storeId === 'all'
|
||||
? stores.filter((s) => s.id !== 'all').map((s) => s.id)
|
||||
: [storeId],
|
||||
[storeId, stores],
|
||||
);
|
||||
const sampleSales = useMemo(
|
||||
() => buildSampleSales({siteIds, range, custom: custom ?? null, serverNow}),
|
||||
[siteIds, range, custom, serverNow],
|
||||
);
|
||||
const sales = withSample(realSales, sampleSales, (d) => !!d?.length);
|
||||
const rows: Sale[] | undefined =
|
||||
sales.status === 'success' || sales.status === 'empty' ? sales.data : undefined;
|
||||
|
||||
const period = useMemo(
|
||||
() => (serverNow ? {range, custom: custom ?? null, serverNow} : null),
|
||||
[range, custom, serverNow],
|
||||
);
|
||||
const summary = useMemo(
|
||||
() => (rows && period ? summarise(rows, period) : null),
|
||||
[rows, period],
|
||||
);
|
||||
const trend = useMemo(
|
||||
() => (rows && period ? salesTrend(rows, period) : []),
|
||||
[rows, period],
|
||||
);
|
||||
|
||||
const todayRows: Sale[] | undefined = isTodayRange
|
||||
? rows
|
||||
: sales.isSample
|
||||
? sampleSales.filter((s) => shopDay(s.at) === shopToday(new Date(serverNow)))
|
||||
: today.status === 'success' || today.status === 'empty'
|
||||
? today.data
|
||||
: undefined;
|
||||
// Revenue is rupees taken; orders is the NUMBER of bills. The captions say
|
||||
// so, because "Sales" beside "Purchases" read as the same thing twice.
|
||||
const todayCount = todayRows?.filter((s) => s.status !== 'void').length;
|
||||
const revenueCaption = isTodayRange
|
||||
? 'Money taken today'
|
||||
: todayRows === undefined
|
||||
? 'Money taken'
|
||||
: `Today ${rupeesFormat(totalPaise(todayRows))}`;
|
||||
const ordersCaption = isTodayRange
|
||||
? 'Bills recorded today'
|
||||
: todayCount === undefined
|
||||
? 'Bills recorded'
|
||||
: `Today ${formatCount(todayCount)} bills`;
|
||||
|
||||
const facts = rows ? salesFacts(rows) : null;
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
eyebrow="Sales & revenue"
|
||||
title="Sales"
|
||||
description={`Revenue and conversion across ${scopeLabel}.`}
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
|
||||
<PageHeader
|
||||
eyebrow="Sales & Revenue Analytics"
|
||||
title="Commerce"
|
||||
description={`Sales, orders, revenue and store performance across ${scopeLabel}.`}
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChartCard
|
||||
title="Revenue"
|
||||
subtitle="Daily takings, from the conversion report"
|
||||
resource={conversion}
|
||||
actions={<SampleTag show={conversion.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
<BarChartView
|
||||
data={report.buckets}
|
||||
xKey="label"
|
||||
xFormat={formatBucketLabel}
|
||||
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}
|
||||
actions={<SampleTag show={sales.isSample} />}
|
||||
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}
|
||||
// Sample rows have no sale behind them to open.
|
||||
onClick={sales.isSample ? undefined : () => 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)} />
|
||||
{sales.isSample ? (
|
||||
<SampleNotice>
|
||||
No sales recorded in this period yet. The revenue, orders and outlet
|
||||
figures below are examples until staff record sales in the merchant
|
||||
app.
|
||||
</SampleNotice>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
No platform resource exists yet for the catalogue or payment methods,
|
||||
so these two are ALWAYS sample and always tagged. Swap each constant
|
||||
for a hook when the endpoint ships.
|
||||
*/}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<StaticPanel
|
||||
title="Top products"
|
||||
subtitle="Best sellers by revenue in this period"
|
||||
actions={<SampleTag show />}
|
||||
>
|
||||
<List density="balanced">
|
||||
{MOCK_TOP_PRODUCTS.map((p, i) => (
|
||||
<ListItem
|
||||
key={p.id}
|
||||
label={`${i + 1}. ${p.name}`}
|
||||
description={`${p.sold} sold`}
|
||||
endContent={formatPaise(p.revenuePaise)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Payment methods"
|
||||
subtitle="Share of sales by how customers paid"
|
||||
actions={<SampleTag show />}
|
||||
>
|
||||
<BarChartView
|
||||
data={MOCK_PAYMENT_METHODS}
|
||||
xKey="method"
|
||||
yFormat={formatPct}
|
||||
height={220}
|
||||
series={[{key: 'share', label: 'Share', color: CHART.brand.cool}]}
|
||||
{summary && facts ? (
|
||||
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
||||
<MetricCard
|
||||
label="Total revenue"
|
||||
value={summary.salesPaise}
|
||||
format={salesFormat}
|
||||
icon={ICONS.revenue}
|
||||
accent="warm"
|
||||
trend={summary.trend.sales}
|
||||
caption={revenueCaption}
|
||||
/>
|
||||
</StaticPanel>
|
||||
<MetricCard
|
||||
label="Avg order value"
|
||||
value={summary.averagePaise}
|
||||
format={rupeesFormat}
|
||||
icon={ICONS.analytics}
|
||||
accent="cool"
|
||||
trend={summary.trend.average}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Conversion"
|
||||
value={conversion.data?.conversionPct ?? null}
|
||||
format={(v) => formatPct(v)}
|
||||
icon={ICONS.analytics}
|
||||
accent="cool"
|
||||
unavailableNote="Not reported"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Enquiries"
|
||||
value={facts.enquiries}
|
||||
format={formatCount}
|
||||
icon={ICONS.commerce}
|
||||
accent="cool"
|
||||
caption="Items asked about, not bought"
|
||||
/>
|
||||
{/* SAMPLE — the sale list records no refund or discount. */}
|
||||
</Grid>
|
||||
) : sales.status === 'error' ? null : (
|
||||
<SkeletonMetricGrid columns={4} />
|
||||
)}
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<PanelCard
|
||||
title="Revenue trend"
|
||||
subtitle={isTodayRange ? 'Hourly sales so far today' : 'Daily sales across the selected period'}
|
||||
actions={<SampleTag show={sales.isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonChart height={220} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{() => <SalesTrendChart data={trend} height={220} />}
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard
|
||||
title="Orders volume trend"
|
||||
subtitle={isTodayRange ? 'Orders per hour so far today' : 'Orders per day across the selected period'}
|
||||
actions={<SampleTag show={sales.isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonChart height={220} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{() => (
|
||||
<BarChartView
|
||||
data={trend}
|
||||
xKey="label"
|
||||
height={220}
|
||||
yFormat={formatCompact}
|
||||
isInteger
|
||||
series={[{key: 'purchases', label: 'Orders'}]}
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<PaymentMethodsPanel sales={sales} />
|
||||
<TopOutletsPanel sales={sales} isSample={sales.isSample} />
|
||||
</Grid>
|
||||
|
||||
<TopProductsPanel />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
|
||||
import {redirect} from 'next/navigation';
|
||||
|
||||
/**
|
||||
* The customer directory, from GET /api/visitors.
|
||||
*
|
||||
* Company-wide, not scoped by the store switcher: a customer belongs to the
|
||||
* business, and somebody who first walked into one branch is the same person
|
||||
* at another.
|
||||
* Customers moved into the Dashboard (the "Customers" section, below Recent
|
||||
* arrivals) and out of the top nav. This route stays so an existing bookmark
|
||||
* or shared link still lands on the directory.
|
||||
*/
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
description="Everyone the cameras have recognised — name them, see their visits, record a purchase."
|
||||
/>
|
||||
<CustomerDirectory />
|
||||
</VStack>
|
||||
);
|
||||
redirect('/dashboard#customers');
|
||||
}
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Collapsible} from '@astryxdesign/core/Collapsible';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {CalendarHeatmap} from '@/shared/components/charts/CalendarHeatmap';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
|
||||
import {KpiRow} from '@/features/dashboard/components/KpiRow';
|
||||
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
|
||||
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {CustomerFlowChart} from '@/features/dashboard/components/CustomerFlowChart';
|
||||
import {EngagementSection} from '@/features/engagement/components/EngagementSection';
|
||||
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
|
||||
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
|
||||
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
|
||||
import {StoreInsights} from '@/features/dashboard/components/StoreInsights';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {
|
||||
useConversionReport,
|
||||
useDashboardKpis,
|
||||
useDashboardPeakHours,
|
||||
useDashboardRewardUsage,
|
||||
useDashboardStoreComparison,
|
||||
useFootfallReport,
|
||||
useRecentVisits,
|
||||
useStoreInsights,
|
||||
} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import type {Granularity} from '@/features/dashboard/types/dashboard';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {greetingFor} from '@/features/dashboard/services/dashboardService';
|
||||
@@ -32,9 +51,8 @@ import {
|
||||
MOCK_CONVERSION_WEEKLY,
|
||||
MOCK_FOOTFALL_DAILY,
|
||||
MOCK_FOOTFALL_WEEKLY,
|
||||
hasSignal,
|
||||
withSample,
|
||||
} from '@/features/dashboard/mocks/dashboardMock';
|
||||
} from '@/shared/mocks/sampleData';
|
||||
import {hasSignal, withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
|
||||
const CHART_HEIGHT = 220;
|
||||
@@ -44,7 +62,7 @@ const CHART_HEIGHT = 220;
|
||||
*
|
||||
* Every number traces to a platform resource — footfall, conversion, arrivals.
|
||||
* The KPI row is always real. The charts fall back to clearly-tagged SAMPLE
|
||||
* data (features/dashboard/mocks) only while a shop has no history, so a new
|
||||
* data (shared/mocks/sampleData.ts) only while a shop has no history, so a new
|
||||
* merchant sees what each panel is for instead of an empty axis.
|
||||
*
|
||||
* To retire the sample, see `shared/mocks/withSample.ts`.
|
||||
@@ -66,6 +84,21 @@ export default function DashboardPage() {
|
||||
visits.isRefreshing ? undefined : visits.data?.cursor,
|
||||
);
|
||||
const scopeLabel = useScopeLabel();
|
||||
const {storeId} = useWorkspace();
|
||||
|
||||
// Supporting analytics, ported from the merchant app. Collapsed by default
|
||||
// and remembered — see CLAUDE.md. All but the first chart are SAMPLE: the
|
||||
// platform reports no hourly heatmap, rewards, period rollup or per-store
|
||||
// comparison, so those read shared/mocks/sampleData.ts inside <SampleScope>.
|
||||
const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
|
||||
'loyaly.dashboard.supporting-analytics',
|
||||
false,
|
||||
);
|
||||
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
||||
const peak = useDashboardPeakHours();
|
||||
const rewardUsage = useDashboardRewardUsage();
|
||||
const comparison = useDashboardStoreComparison();
|
||||
const insights = useStoreInsights();
|
||||
|
||||
// Chart feeds: real when the platform has signal, sample otherwise.
|
||||
const footfallChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
|
||||
@@ -117,16 +150,11 @@ export default function DashboardPage() {
|
||||
actions={<SampleTag show={footfallChart.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
<AreaChartView
|
||||
data={report.buckets}
|
||||
xKey="label"
|
||||
xFormat={formatBucketLabel}
|
||||
<CalendarHeatmap
|
||||
data={report.buckets.map((b) => ({label: b.label, value: b.visitors}))}
|
||||
accent="cool"
|
||||
unit="visitors"
|
||||
height={CHART_HEIGHT}
|
||||
yFormat={formatCompact}
|
||||
isInteger
|
||||
series={[
|
||||
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
@@ -235,9 +263,116 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<EngagementSection />
|
||||
|
||||
<Collapsible
|
||||
isOpen={isSupportingOpen}
|
||||
onOpenChange={setSupportingOpen}
|
||||
trigger={
|
||||
<VStack gap={0.5} hAlign="start">
|
||||
{/* Text, not Heading: Collapsible renders the trigger inside a
|
||||
<button>, and a heading in button content is invalid markup. */}
|
||||
<Text size="base" weight="semibold">
|
||||
Supporting analytics
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Visitors against purchases, peak hours, reward usage, the period
|
||||
rollup and store comparison
|
||||
</Text>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5} className="pt-4">
|
||||
{/* Real: the same two reports as the Customer flow card, joined by
|
||||
bucket, and sample as a PAIR for the same reason. There is no
|
||||
per-day Conversion line beside it — deriving purchases ÷
|
||||
visitors here would invent a definition the reports may not
|
||||
share (see kpiBuilder). */}
|
||||
<ChartCard
|
||||
title="Visitors vs purchases"
|
||||
subtitle="The gap is the conversion opportunity"
|
||||
resource={flowChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={flowChart.isSample} />}
|
||||
>
|
||||
{(report) => {
|
||||
const purchases = new Map(
|
||||
(flowIsSample || !conversion.data
|
||||
? MOCK_CONVERSION_DAILY
|
||||
: conversion.data
|
||||
).buckets.map((b) => [b.label, b.purchases]),
|
||||
);
|
||||
return (
|
||||
<LineChartView
|
||||
data={report.buckets.map((b) => ({
|
||||
label: b.label,
|
||||
visitors: b.visitors,
|
||||
purchases: purchases.get(b.label) ?? 0,
|
||||
}))}
|
||||
xKey="label"
|
||||
xFormat={formatBucketLabel}
|
||||
height={CHART_HEIGHT}
|
||||
yFormat={formatCompact}
|
||||
isInteger
|
||||
series={[
|
||||
{key: 'visitors', label: 'Visitors'},
|
||||
{key: 'purchases', label: 'Purchases'},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ChartCard>
|
||||
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
subtitle="Where the week's footfall actually lands"
|
||||
resource={peak}
|
||||
height={200}
|
||||
>
|
||||
{(d) => <HeatmapGrid data={d} />}
|
||||
</ChartCard>
|
||||
|
||||
<RewardUsageChart resource={rewardUsage} />
|
||||
</Grid>
|
||||
|
||||
<PerformancePanel
|
||||
granularity={granularity}
|
||||
onGranularityChange={setGranularity}
|
||||
/>
|
||||
|
||||
{/* Comparing stores is meaningless when scoped to one of them. */}
|
||||
{storeId === 'all' ? (
|
||||
<StoreComparisonPanel resource={comparison} />
|
||||
) : null}
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
|
||||
{/* What to do next — last because it is the conclusion. SAMPLE. */}
|
||||
<SampleScope>
|
||||
<StoreInsights resource={insights} />
|
||||
</SampleScope>
|
||||
|
||||
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
|
||||
|
||||
<EngagementSection />
|
||||
{/*
|
||||
Customers live here rather than in the top nav: who walked in (the
|
||||
feed above) and who they are (the directory) are one question. The
|
||||
directory is company-wide — it ignores the store switcher, because a
|
||||
customer belongs to the business. `/customers` redirects to this
|
||||
anchor so old links still land.
|
||||
*/}
|
||||
<VStack gap={4} id="customers">
|
||||
<SectionHeader
|
||||
title="Customers"
|
||||
subtitle="Everyone the cameras have recognised — name them, see their visits, record a purchase."
|
||||
/>
|
||||
<CustomerDirectory />
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
|
||||
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
|
||||
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
|
||||
import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
|
||||
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||
import {
|
||||
useLytsActivity,
|
||||
useRedemptions,
|
||||
useRewards,
|
||||
} from '@/features/lyts/hooks/useLyts';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {usageRate} from '@/features/lyts/services/lytsService';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatLyt,
|
||||
formatPct,
|
||||
} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* LYTs.
|
||||
* The LYT programme — activities and the rewards they pay out in.
|
||||
*
|
||||
* 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.
|
||||
* Ordered by urgency rather than by data type: what's expiring, then the
|
||||
* headline numbers, then what customers can DO to earn, then the catalogue of
|
||||
* what they spend on, then the analysis. A merchant opening this page is
|
||||
* usually here because something needs extending or pausing.
|
||||
*
|
||||
* 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.
|
||||
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
|
||||
* rupee liability — which is why they lead.
|
||||
*
|
||||
* ── The activity half ─────────────────────────────────────────────────────
|
||||
* Activities sit here, above rewards, because they are the earning side of
|
||||
* the same ledger: an activity issues LYTs, a reward spends them, and a
|
||||
* merchant tuning the programme is trading one against the other. The
|
||||
* dashboard shows six activities as a summary of engagement; this page is
|
||||
* where all ten are managed, and it reads the SAME activity model — see
|
||||
* ActivityProgramme.
|
||||
*
|
||||
* ── Sample data ───────────────────────────────────────────────────────────
|
||||
* The platform has no rewards, redemption or activity resource, so every
|
||||
* figure here comes from shared/mocks/sampleData.ts. The page says so once at
|
||||
* the top and every panel wears the token (SampleScope). Retire it with the
|
||||
* rest of the sample data — see shared/mocks/withSample.ts.
|
||||
*/
|
||||
export default function LytsPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Lyts"
|
||||
description="Loyalty rewards are not part of the current product."
|
||||
/>
|
||||
const rewards = useRewards();
|
||||
const redemptions = useRedemptions();
|
||||
const activity = useLytsActivity();
|
||||
|
||||
<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>
|
||||
const columns = useMetricColumns();
|
||||
|
||||
// "Now" comes from the SERVER's clock, carried on every response.
|
||||
//
|
||||
// Date.now() here would be impure during render, would disagree between the
|
||||
// SSR and hydration passes, and would report the wrong countdown to anyone
|
||||
// whose device clock is off — on a panel whose entire job is telling a
|
||||
// merchant an offer expires in two days. Falls back to 0 only before the
|
||||
// first response lands, when no expiry panel is rendered anyway.
|
||||
const nowMs = rewards.meta ? new Date(rewards.meta.generatedAt).getTime() : 0;
|
||||
|
||||
return (
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Lyts"
|
||||
description="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1."
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
<SampleNotice>
|
||||
The platform does not record LYT rewards, claims or redemptions yet.
|
||||
Every figure on this page is an example of what the programme will
|
||||
show — none of it is money your customers hold.
|
||||
</SampleNotice>
|
||||
|
||||
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
|
||||
|
||||
<SectionHeader title="Programme overview" />
|
||||
|
||||
<AsyncBoundary
|
||||
resource={rewards}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const active = rows.filter(
|
||||
(r) => r.status === 'active' || r.status === 'expiring',
|
||||
);
|
||||
const claimed = rows.reduce((a, r) => a + r.claimed, 0);
|
||||
const used = rows.reduce((a, r) => a + r.used, 0);
|
||||
const outstanding = rows.reduce(
|
||||
(a, r) => a + (r.claimed - r.used) * r.costLyt,
|
||||
0,
|
||||
);
|
||||
const mostClaimed = rows.reduce((a, b) =>
|
||||
b.claimed > a.claimed ? b : a,
|
||||
);
|
||||
const leastUsed = rows.reduce((a, b) =>
|
||||
usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used)
|
||||
? b
|
||||
: a,
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<MetricCard
|
||||
label="Active rewards"
|
||||
value={active.length}
|
||||
format={(v) => String(Math.round(v))}
|
||||
icon={ICONS.activeRewards}
|
||||
footer={<Caption text={`${rows.length} in catalogue`} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Redemption rate"
|
||||
value={usageRate(claimed, used)}
|
||||
format={(v) => formatPct(v, 0)}
|
||||
icon={ICONS.lyts}
|
||||
deltaPct={usageRate(claimed, used) - 50}
|
||||
footer={
|
||||
<Caption
|
||||
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Most claimed"
|
||||
value={mostClaimed.claimed}
|
||||
format={formatCompact}
|
||||
icon={ICONS.up}
|
||||
footer={<Caption text={mostClaimed.name} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Outstanding liability"
|
||||
value={outstanding}
|
||||
format={formatLyt}
|
||||
icon={ICONS.alert}
|
||||
footer={
|
||||
<Caption
|
||||
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
{/*
|
||||
The earning side, before the spending side. All ten activities, their
|
||||
status and their LYT cost — the same model the dashboard summarises.
|
||||
*/}
|
||||
<ActivityProgramme />
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Redemption"
|
||||
subtitle="LYTs redeemed per day"
|
||||
resource={redemptions}
|
||||
>
|
||||
{(d) => (
|
||||
<AreaChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'purchases', label: 'Redeemed'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Issued vs redeemed"
|
||||
subtitle="The gap is LYTs still in customers' hands"
|
||||
resource={redemptions}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'visitors', label: 'Issued'},
|
||||
{key: 'purchases', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<ChartCard
|
||||
title="Top rewards"
|
||||
subtitle="Claimed vs redeemed, by reward"
|
||||
resource={rewards}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'claimed', label: 'Claimed'},
|
||||
{key: 'used', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<RewardGrid resource={rewards} nowMs={nowMs} />
|
||||
|
||||
<RewardPerformanceTable resource={rewards} />
|
||||
|
||||
<ActivityTimeline resource={activity} limit={6} height={285} />
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the sparkline on metrics with no meaningful time series —
|
||||
* "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
|
||||
* for exactly this so the card keeps its height and the row stays aligned.
|
||||
*/
|
||||
function Caption({text}: {text: string}) {
|
||||
return (
|
||||
<Text size="xsm" color="secondary">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {StaffKpis} from '@/features/staff/components/StaffKpis';
|
||||
import {AttendanceChart} from '@/features/staff/components/AttendanceChart';
|
||||
import {Leaderboard} from '@/features/staff/components/Leaderboard';
|
||||
import {
|
||||
useStaffAttendance,
|
||||
useStaffList,
|
||||
useStaffSummary,
|
||||
} from '@/features/staff/hooks/useStaff';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {TeamTable} from '@/features/team/components/TeamTable';
|
||||
import {useTeam} from '@/features/team/hooks/useTeam';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
|
||||
/**
|
||||
* Leaderboard.
|
||||
* The team.
|
||||
*
|
||||
* ── 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.
|
||||
* Ordered attendance-first: the question that brings a merchant here in the
|
||||
* morning is "who is in today", and only then "who is performing".
|
||||
*
|
||||
* 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.
|
||||
* ── Sample data ───────────────────────────────────────────────────────────
|
||||
* The platform's `/api/team` is who can SIGN IN to the console — there is no
|
||||
* attendance, shift, sales-per-head or performance score in the contract. So
|
||||
* everything above the Team table comes from shared/mocks/sampleData.ts and
|
||||
* wears the "Sample data" token; the Team table is the one real panel.
|
||||
*/
|
||||
export default function LeaderboardPage() {
|
||||
const summary = useStaffSummary();
|
||||
const staff = useStaffList();
|
||||
const attendance = useStaffAttendance();
|
||||
const scopeLabel = useScopeLabel();
|
||||
const team = useTeam();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Leaderboard"
|
||||
description="People with access to this console."
|
||||
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<SampleNotice>
|
||||
The platform does not record shifts, attendance or which team member
|
||||
made a sale yet. The ranking, attendance and sales figures below are
|
||||
examples; the Team table at the bottom is real.
|
||||
</SampleNotice>
|
||||
|
||||
<SectionHeader title="Team overview" />
|
||||
<StaffKpis resource={summary} />
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<AttendanceChart resource={attendance} />
|
||||
|
||||
<ChartCard
|
||||
title="Sales by team member"
|
||||
subtitle="Top 8 by transactions in the selected period"
|
||||
resource={staff}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows]
|
||||
.sort((a, b) => b.salesCount - a.salesCount)
|
||||
.slice(0, 8)
|
||||
.map((m) => ({
|
||||
// First name only — full names overflow the axis at this
|
||||
// width and the leaderboard below carries the detail.
|
||||
name: m.name.split(' ')[0],
|
||||
sales: m.salesCount,
|
||||
rewards: m.rewardsIssued,
|
||||
}))}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'sales', label: 'Sales'},
|
||||
{key: 'rewards', label: 'Rewards issued'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Leaderboard resource={staff} />
|
||||
|
||||
{/*
|
||||
The per-person card grid (StaffGrid) is off the page for now: at
|
||||
~245px a card it does not scale to a large roster. The component
|
||||
is kept in features/staff/components for a compact redesign
|
||||
(store groups with small person tiles) — re-add it here then.
|
||||
*/}
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
224
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
224
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import {use} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {BreadcrumbItem, Breadcrumbs} from '@astryxdesign/core/Breadcrumbs';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useStore} from '@/features/stores/hooks/useStores';
|
||||
import {
|
||||
useDashboardActivity,
|
||||
useDashboardPeakHours,
|
||||
useDashboardTimeseries,
|
||||
useSampleKpis,
|
||||
} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatInrCompact,
|
||||
formatPct,
|
||||
} from '@/shared/utils/format';
|
||||
import type {StoreStatus} from '@/features/stores/types/store';
|
||||
|
||||
const STATUS: Record<
|
||||
StoreStatus,
|
||||
{label: string; variant: 'success' | 'warning' | 'error'}
|
||||
> = {
|
||||
open: {label: 'Open', variant: 'success'},
|
||||
maintenance: {label: 'Maintenance', variant: 'warning'},
|
||||
closed: {label: 'Closed', variant: 'error'},
|
||||
};
|
||||
|
||||
/**
|
||||
* One store's dashboard.
|
||||
*
|
||||
* Deliberately the SAME chart components as the main dashboard, scoped by the
|
||||
* route's storeId rather than the shared scope's. Building store-specific chart
|
||||
* variants would double the surface area and guarantee the two drift apart —
|
||||
* the only thing that differs here is which store the query asks about.
|
||||
*/
|
||||
export default function StoreDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{storeId: string}>;
|
||||
}) {
|
||||
const {storeId} = use(params);
|
||||
const {range} = useWorkspace();
|
||||
const scope = {range, storeId};
|
||||
|
||||
const store = useStore(storeId);
|
||||
const kpis = useSampleKpis(scope);
|
||||
const series = useDashboardTimeseries(scope);
|
||||
const peak = useDashboardPeakHours(scope);
|
||||
const activity = useDashboardActivity(scope);
|
||||
|
||||
const columns = useMetricColumns();
|
||||
const name = store.data?.name ?? 'Store';
|
||||
|
||||
// SAMPLE — every figure on this page reads shared/mocks/sampleData.ts; the
|
||||
// platform reports no per-store analytics. A real shop id is not a sample
|
||||
// store, so it gets the not-found state rather than invented numbers.
|
||||
if (store.data === null) {
|
||||
return (
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="Store not found"
|
||||
description="Your shops and their cameras are under Store → Live cameras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<Breadcrumbs variant="supporting">
|
||||
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrent>{name}</BreadcrumbItem>
|
||||
</Breadcrumbs>
|
||||
|
||||
<PageHeader
|
||||
title={name}
|
||||
description={
|
||||
store.data
|
||||
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
|
||||
: 'Loading store…'
|
||||
}
|
||||
// Store scope is the route here, so only the period is selectable.
|
||||
controls={<ScopeControls hasStore={false} />}
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{store.data ? (
|
||||
<Badge
|
||||
variant={STATUS[store.data.status].variant}
|
||||
label={STATUS[store.data.status].label}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="All stores"
|
||||
href="/stores"
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<SampleNotice>
|
||||
The platform does not report per-store sales, staffing or trends yet.
|
||||
This page shows an example of the store view; your real shops and
|
||||
cameras are on the Store page.
|
||||
</SampleNotice>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={kpis}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{rows.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={
|
||||
k.id === 'visitors'
|
||||
? ICONS.visitors
|
||||
: k.id === 'purchases'
|
||||
? ICONS.purchases
|
||||
: k.id === 'revenue'
|
||||
? ICONS.revenue
|
||||
: ICONS.activeRewards
|
||||
}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Footfall"
|
||||
subtitle="Visitors per day"
|
||||
resource={series}
|
||||
>
|
||||
{(d) => (
|
||||
<AreaChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
||||
{(d) => (
|
||||
<BarChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Conversion"
|
||||
subtitle="Share of visitors who bought"
|
||||
resource={series}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={(v) => formatPct(v, 0)}
|
||||
series={[{key: 'conversion', label: 'Conversion'}]}
|
||||
reference={{
|
||||
y: 22,
|
||||
label: 'Network benchmark',
|
||||
tone: 'positive',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
subtitle="Where this store's week actually lands"
|
||||
resource={peak}
|
||||
height={200}
|
||||
>
|
||||
{(d) => <HeatmapGrid data={d} />}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<ActivityTimeline resource={activity} limit={6} height={285} />
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
);
|
||||
}
|
||||
61
src/app/(workspace)/stores/cameras/page.tsx
Normal file
61
src/app/(workspace)/stores/cameras/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Breadcrumbs, BreadcrumbItem} from '@astryxdesign/core/Breadcrumbs';
|
||||
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 {useSites} from '@/features/stores/hooks/useSites';
|
||||
import {ShopSection} from '@/features/stores/components/ShopSection';
|
||||
|
||||
/**
|
||||
* Live cameras — the estate, from GET /api/sites.
|
||||
*
|
||||
* Reached from the "Live cameras" button on the Store page. Every shop, its
|
||||
* cameras, and the shop PC that watches them: live view, health check,
|
||||
* enrolment and camera management all live here, so the Store page itself can
|
||||
* open on performance.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default function LiveCamerasPage() {
|
||||
const sites = useSites();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<Breadcrumbs variant="supporting">
|
||||
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrent>Live cameras</BreadcrumbItem>
|
||||
</Breadcrumbs>
|
||||
|
||||
<PageHeader
|
||||
title="Live cameras"
|
||||
description="Every shop, its cameras, and the shop PC that watches them."
|
||||
/>
|
||||
|
||||
<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."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<VStack gap={6}>
|
||||
{rows.map((site) => (
|
||||
<ShopSection key={site.id} site={site} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
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 {useSites} from '@/features/stores/hooks/useSites';
|
||||
import {ShopSection} from '@/features/stores/components/ShopSection';
|
||||
import {StoreGrid} from '@/features/stores/components/StoreGrid';
|
||||
import {useStoreList} from '@/features/stores/hooks/useStores';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* The estate, from GET /api/sites.
|
||||
* The Store page opens on performance; the cameras are one click away.
|
||||
*
|
||||
* 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.
|
||||
* Shops, cameras, live view and the shop PC moved to /stores/cameras behind
|
||||
* the "Live cameras" action, so a merchant checking how stores are doing is
|
||||
* not scrolling past camera management to get there.
|
||||
*
|
||||
* The performance grid is SAMPLE: the platform reports no per-store revenue,
|
||||
* staffing or trend, so these cards (and the /stores/[storeId] pages they
|
||||
* open) read fixtures. The cameras page is real.
|
||||
*/
|
||||
export default function StoresPage() {
|
||||
const sites = useSites();
|
||||
const performance = useStoreList();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Store"
|
||||
description="Every shop, its cameras, and the shop PC that watches them."
|
||||
/>
|
||||
|
||||
<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."
|
||||
description="Visitors, sales and staffing for every store."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Live cameras"
|
||||
icon={<Icon icon={ICONS.cameras} />}
|
||||
href="/stores/cameras"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<VStack gap={6}>
|
||||
{rows.map((site) => (
|
||||
<ShopSection key={site.id} site={site} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
/>
|
||||
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<SampleNotice>
|
||||
The platform does not report per-store sales, staffing or trends
|
||||
yet, so these store cards are examples. Your real shops and cameras
|
||||
are under Live cameras.
|
||||
</SampleNotice>
|
||||
<StoreGrid resource={performance} />
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), undefined, query.custom);
|
||||
try {
|
||||
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), undefined, query.custom);
|
||||
const site = toSiteParam(query.storeId);
|
||||
try {
|
||||
const [activities, impact] = await Promise.all([
|
||||
|
||||
@@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic';
|
||||
/** GET /api/campaigns — each campaign's funnel over the workspace window. */
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), undefined, query.custom);
|
||||
try {
|
||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
||||
return (list ?? []).map(toCampaign);
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
|
||||
const wantsPrevious = url.searchParams.get('compare') === 'previous';
|
||||
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), bucket, query.custom);
|
||||
const site = toSiteParam(query.storeId);
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), bucket, query.custom);
|
||||
const site = toSiteParam(query.storeId);
|
||||
|
||||
// Sequential would double the latency of every dashboard load; both
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), undefined, query.custom);
|
||||
try {
|
||||
return toJourney(
|
||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
||||
|
||||
@@ -42,17 +42,49 @@ export function toSale(s: ApiSale): Sale {
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
/** 2,000 sales. A window past this answers with the newest 2,000. */
|
||||
const MAX_PAGES = 10;
|
||||
|
||||
/**
|
||||
* Every sale in the window, not just the first page.
|
||||
*
|
||||
* The Sales screen totals these (Today, Overall, the heatmap), so a single
|
||||
* 50-row page would silently under-report any shop with a busy month. The
|
||||
* platform pages by `limit`/`offset` (limit capped at 200).
|
||||
*
|
||||
* Deduplicated by id, and it stops when a page adds nothing new: a platform
|
||||
* build that ignores `offset` would otherwise hand back page one ten times and
|
||||
* the totals would be ten times too high.
|
||||
*/
|
||||
async function listWindow(
|
||||
token: string,
|
||||
filter: {site?: string; from: string; to: string},
|
||||
): Promise<{items: ApiSale[]}> {
|
||||
const seen = new Map<string, ApiSale>();
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const {items = []} = await salesApi.list(token, {
|
||||
...filter,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
const before = seen.size;
|
||||
for (const s of items) seen.set(s.id, s);
|
||||
if (items.length < PAGE_SIZE || seen.size === before) break;
|
||||
}
|
||||
return {items: [...seen.values()]};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), undefined, query.custom);
|
||||
try {
|
||||
return await salesApi.list(token, {
|
||||
return await listWindow(token, {
|
||||
site: toSiteParam(query.storeId),
|
||||
from: window.from,
|
||||
to: window.to,
|
||||
limit: 50,
|
||||
});
|
||||
} catch (err) {
|
||||
// If the upstream platform has not deployed /api/sales yet,
|
||||
|
||||
315
src/features/commerce/components/CommercePanels.tsx
Normal file
315
src/features/commerce/components/CommercePanels.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {proportional, pixel, type TableColumn} from '@astryxdesign/core/Table';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {StatPair} from '@/shared/components/patterns/StatPair';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {
|
||||
SAMPLE_DISCOUNT_RATE,
|
||||
SAMPLE_PAYMENT_MIX,
|
||||
SAMPLE_REFUND_RATE,
|
||||
buildTopProducts,
|
||||
storeName as sampleStoreName,
|
||||
type SampleProduct,
|
||||
type SampleStockStatus,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {formatCompact, formatInr, formatInrCompact} from '@/shared/utils/format';
|
||||
import {
|
||||
byOutlet,
|
||||
salesFacts,
|
||||
totalPaise,
|
||||
} from '@/features/commerce/services/salesAnalysis';
|
||||
import {formatPaise} from '@/features/commerce/services/money';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
* The Commerce page's analytics panels, in the merchant app's layout.
|
||||
*
|
||||
* REAL: Top performing outlets and Sales insights — both computed from the
|
||||
* platform's sales, so they move with every sale recorded.
|
||||
*
|
||||
* SAMPLE: Payment methods, Top products and the refund/discount lines of the
|
||||
* Revenue summary. The sales resource carries no payment method, discount,
|
||||
* refund or (in the list) product line. Each sits in a <SampleScope>, and
|
||||
* every rupee figure is the sample SPLIT applied to the REAL revenue, so the
|
||||
* panels can never disagree with the total above them. The values themselves
|
||||
* live in shared/mocks/sampleData.ts.
|
||||
*/
|
||||
|
||||
const noSales = (
|
||||
<EmptyPanel
|
||||
icon="commerce"
|
||||
title="No sales in this period"
|
||||
description="This fills in as staff record sales in the merchant app."
|
||||
/>
|
||||
);
|
||||
|
||||
export function PaymentMethodsPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||
return (
|
||||
<SampleScope>
|
||||
<PanelCard
|
||||
title="Payment methods"
|
||||
subtitle="Share of revenue by how customers paid"
|
||||
resource={sales}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{(rows) => {
|
||||
const revenue = totalPaise(rows) / 100;
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
{SAMPLE_PAYMENT_MIX.map((p) => (
|
||||
<ProgressBar
|
||||
key={p.method}
|
||||
label={p.method}
|
||||
value={p.pct}
|
||||
variant="neutral"
|
||||
hasValueLabel
|
||||
formatValueLabel={(v) =>
|
||||
`${v}% · ${formatInrCompact((revenue * p.pct) / 100)}`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
</SampleScope>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopOutletsPanel({
|
||||
sales,
|
||||
isSample = false,
|
||||
}: {
|
||||
sales: Resource<Sale[]>;
|
||||
isSample?: boolean;
|
||||
}) {
|
||||
const {stores} = useWorkspace();
|
||||
// Sample sales fall back to the fixture roster when the merchant has no
|
||||
// stores loaded, so those ids resolve through sampleStoreName.
|
||||
const name = (id: string) =>
|
||||
stores.find((s) => s.id === id)?.name ?? (isSample ? sampleStoreName(id) : id);
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Top performing outlets"
|
||||
subtitle="Sales by store in the selected period"
|
||||
actions={<SampleTag show={isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{(rows) => {
|
||||
const outlets = byOutlet(rows);
|
||||
const top = outlets[0]?.paise || 1;
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
{outlets.map((o) => (
|
||||
<ProgressBar
|
||||
key={o.siteId}
|
||||
label={name(o.siteId)}
|
||||
value={o.paise}
|
||||
max={top}
|
||||
variant="neutral"
|
||||
hasValueLabel
|
||||
formatValueLabel={() =>
|
||||
`${formatInrCompact(o.paise / 100)} · ${formatCompact(o.purchases)} orders`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
const STOCK: Record<SampleStockStatus, {label: string; variant: 'success' | 'warning' | 'error'}> = {
|
||||
in_stock: {label: 'In stock', variant: 'success'},
|
||||
low_stock: {label: 'Low', variant: 'warning'},
|
||||
out_of_stock: {label: 'Out', variant: 'error'},
|
||||
};
|
||||
|
||||
const PRODUCT_COLUMNS: TableColumn<SampleProduct>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Product',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0.5}>
|
||||
<Text size="base" weight="semibold">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.category}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'units',
|
||||
header: 'Units',
|
||||
width: pixel(80),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="base">{formatCompact(row.units)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'revenueInr',
|
||||
header: 'Revenue',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="base" weight="medium">
|
||||
{formatInrCompact(row.revenueInr)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deltaPct',
|
||||
header: 'Change',
|
||||
width: pixel(88),
|
||||
align: 'end',
|
||||
renderCell: (row) => <MetricDelta value={row.deltaPct} />,
|
||||
},
|
||||
{
|
||||
key: 'stockStatus',
|
||||
header: 'Stock',
|
||||
width: pixel(88),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Badge variant={STOCK[row.stockStatus].variant} label={STOCK[row.stockStatus].label} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function TopProductsPanel() {
|
||||
const {storeId, range} = useScope();
|
||||
const products = useSampleResource(() => buildTopProducts(storeId, range), [storeId, range]);
|
||||
|
||||
return (
|
||||
<SampleScope>
|
||||
<PanelCard
|
||||
title="Top products"
|
||||
subtitle="Best sellers by revenue"
|
||||
resource={products}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={PRODUCT_COLUMNS}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="compact"
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
</SampleScope>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalesInsightsPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||
const {stores} = useWorkspace();
|
||||
const name = (id: string) => stores.find((s) => s.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Sales insights"
|
||||
subtitle="What stands out this period"
|
||||
resource={sales}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{(rows) => {
|
||||
const facts = salesFacts(rows);
|
||||
const lead = byOutlet(rows)[0];
|
||||
const asked = facts.enquiries + facts.purchasedLines;
|
||||
const items = [
|
||||
facts.peakHour && {
|
||||
id: 'peak',
|
||||
label: `${String(facts.peakHour.hour).padStart(2, '0')}:00 is the busiest hour`,
|
||||
description: `${facts.peakHour.pct}% of sales in this period landed in that hour.`,
|
||||
badge: <Badge variant="success" label="Staffing" />,
|
||||
},
|
||||
facts.enquiries > 0 && {
|
||||
id: 'enquiries',
|
||||
label: `${formatCompact(facts.enquiries)} items asked about but not bought`,
|
||||
description: `${Math.round((facts.enquiries / asked) * 100)}% of items customers raised did not make it to the bill.`,
|
||||
badge: <Badge variant="warning" label="Opportunity" />,
|
||||
},
|
||||
lead && stores.length > 1 && {
|
||||
id: 'outlet',
|
||||
label: `${name(lead.siteId)} leads on sales`,
|
||||
description: `${formatInr(lead.paise / 100)} from ${formatCompact(lead.purchases)} orders.`,
|
||||
badge: <Badge variant="success" label="Top store" />,
|
||||
},
|
||||
facts.largest && {
|
||||
id: 'largest',
|
||||
label: `Largest sale ${formatPaise(facts.largest.totalPaise)}`,
|
||||
description: [facts.largest.invoiceNo, name(facts.largest.siteId)]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
badge: null,
|
||||
},
|
||||
].filter(Boolean) as {id: string; label: string; description: string; badge: React.ReactNode}[];
|
||||
|
||||
return (
|
||||
<List density="balanced">
|
||||
{items.map((i) => (
|
||||
<ListItem
|
||||
key={i.id}
|
||||
label={i.label}
|
||||
description={i.description}
|
||||
endContent={i.badge}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function RevenueSummaryPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||
return (
|
||||
<SampleScope>
|
||||
<PanelCard
|
||||
title="Revenue summary"
|
||||
subtitle="Gross to net — gross is real, discounts and refunds are examples"
|
||||
resource={sales}
|
||||
loading={<SkeletonRows count={2} />}
|
||||
empty={noSales}
|
||||
>
|
||||
{(rows) => {
|
||||
const gross = totalPaise(rows) / 100;
|
||||
const discounts = gross * SAMPLE_DISCOUNT_RATE;
|
||||
const refunds = gross * SAMPLE_REFUND_RATE;
|
||||
return (
|
||||
<Grid columns={2} gap={5}>
|
||||
<StatPair label="Gross sales" value={formatInr(gross)} />
|
||||
<StatPair label="Discounts" value={`− ${formatInr(discounts)}`} />
|
||||
<StatPair label="Refunds" value={`− ${formatInr(refunds)}`} />
|
||||
<StatPair label="Net revenue" value={formatInr(gross - discounts - refunds)} />
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
</SampleScope>
|
||||
);
|
||||
}
|
||||
97
src/features/commerce/components/SalesTrendChart.tsx
Normal file
97
src/features/commerce/components/SalesTrendChart.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import {useId} from 'react';
|
||||
import {Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {ChartFrame, AXIS_TICK} from '@/shared/components/charts/ChartFrame';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {useChartMotion} from '@/shared/components/charts/useChartMotion';
|
||||
import {formatCount, formatInr, formatInrCompact} from '@/shared/utils/format';
|
||||
import type {TrendPoint} from '@/features/commerce/services/salesAnalysis';
|
||||
|
||||
/**
|
||||
* Daily sales across the selected period — compact, one area in brand warm
|
||||
* (sales are money, and money is warm on this console).
|
||||
*
|
||||
* Its own small chart rather than AreaChartView because the tooltip needs two
|
||||
* units at once — rupees for sales, a plain count for purchases — and the
|
||||
* shared tooltip formats every series with the one `yFormat`.
|
||||
*/
|
||||
export function SalesTrendChart({
|
||||
data,
|
||||
height = 180,
|
||||
}: {
|
||||
data: TrendPoint[];
|
||||
height?: number;
|
||||
}) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const motion = useChartMotion(true);
|
||||
|
||||
return (
|
||||
<ChartFrame height={height}>
|
||||
<AreaChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
|
||||
<defs>
|
||||
<linearGradient id={`sales-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART.brand.warm} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={CHART.brand.warm} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={CHART.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickCount={4}
|
||||
tickFormatter={(v: number) => formatInrCompact(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{stroke: CHART.axisLine}}
|
||||
content={({active, payload}) => {
|
||||
const p = active ? (payload?.[0]?.payload as TrendPoint | undefined) : undefined;
|
||||
if (!p) return null;
|
||||
return (
|
||||
<Card elevation="high" padding={3}>
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" color="secondary">
|
||||
{p.label}
|
||||
</Text>
|
||||
<Text size="sm" weight="semibold">
|
||||
Sales {formatInr(p.sales)}
|
||||
</Text>
|
||||
<Text size="sm">Purchases {formatCount(p.purchases)}</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Sales"
|
||||
stroke={CHART.brand.warm}
|
||||
strokeWidth={2.5}
|
||||
fill={`url(#sales-${uid})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: CHART.brand.warm,
|
||||
strokeWidth: 2,
|
||||
fill: 'var(--color-background-surface)',
|
||||
}}
|
||||
{...motion}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
@@ -9,3 +9,16 @@ import type {Sale} from '@/features/commerce/types/sale';
|
||||
export function useSales(): Resource<Sale[]> {
|
||||
return useResource(saleRepository.list(useScope()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's sales for the selected store, whatever range is picked — the Sales
|
||||
* card's "Today" line. Its own request because a custom range may not include
|
||||
* today at all. `enabled: false` sends nothing (the page already has today's
|
||||
* rows when the range IS today).
|
||||
*/
|
||||
export function useTodaySales(enabled = true): Resource<Sale[]> {
|
||||
const {storeId} = useScope();
|
||||
return useResource(
|
||||
enabled ? saleRepository.list({storeId, range: 'today'}) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the Sales page.
|
||||
*
|
||||
* - `MOCK_SALES` stands in for the sales list only while the platform returns
|
||||
* none (see `shared/mocks/withSample.ts`). Sample rows never open the detail
|
||||
* dialog — there is no sale behind them to fetch.
|
||||
* - `MOCK_TOP_PRODUCTS` and `MOCK_PAYMENT_METHODS` have NO platform resource
|
||||
* yet. They are always sample and always tagged. When the catalogue and
|
||||
* payment endpoints ship, replace them with hooks and delete this file.
|
||||
*
|
||||
* Money is integer paise, like the real contract.
|
||||
*/
|
||||
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
function sale(
|
||||
n: number,
|
||||
customer: string | null,
|
||||
staff: string,
|
||||
totalPaise: number,
|
||||
purchased: number,
|
||||
enquired: number,
|
||||
at: string,
|
||||
): Sale {
|
||||
return {
|
||||
id: `sample-sale-${n}`,
|
||||
invoiceNo: `INV-${String(2400 + n).padStart(5, '0')}`,
|
||||
siteId: 'sample',
|
||||
customerRef: null,
|
||||
customerLabel: customer,
|
||||
staffName: staff,
|
||||
totalPaise,
|
||||
currency: 'INR',
|
||||
status: 'completed',
|
||||
at,
|
||||
purchasedLines: purchased,
|
||||
enquiryLines: enquired,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_SALES: Sale[] = [
|
||||
sale(18, 'Priya S.', 'Arjun', 248_900, 3, 1, '2026-09-25T11:42:00Z'),
|
||||
sale(17, 'Rahul M.', 'Divya', 89_900, 1, 0, '2026-09-25T10:15:00Z'),
|
||||
sale(16, null, 'Arjun', 159_800, 2, 2, '2026-09-24T18:03:00Z'),
|
||||
sale(15, 'Kavya R.', 'Meena', 412_500, 4, 0, '2026-09-24T16:27:00Z'),
|
||||
sale(14, 'Sanjay K.', 'Divya', 64_900, 1, 1, '2026-09-24T13:50:00Z'),
|
||||
sale(13, 'Anitha P.', 'Meena', 198_000, 2, 0, '2026-09-23T19:11:00Z'),
|
||||
];
|
||||
|
||||
export interface SampleProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
sold: number;
|
||||
revenuePaise: number;
|
||||
}
|
||||
|
||||
export const MOCK_TOP_PRODUCTS: SampleProduct[] = [
|
||||
{id: 'p1', name: 'Cotton kurta — indigo', sold: 42, revenuePaise: 5_451_600},
|
||||
{id: 'p2', name: 'Silk dupatta', sold: 31, revenuePaise: 3_689_000},
|
||||
{id: 'p3', name: 'Linen shirt — white', sold: 27, revenuePaise: 2_427_300},
|
||||
{id: 'p4', name: 'Handloom saree', sold: 12, revenuePaise: 5_388_000},
|
||||
{id: 'p5', name: 'Leather sandals', sold: 19, revenuePaise: 1_519_000},
|
||||
];
|
||||
|
||||
export interface SamplePaymentMethod {
|
||||
method: string;
|
||||
/** Share of sales by count, as a percentage. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
export const MOCK_PAYMENT_METHODS: SamplePaymentMethod[] = [
|
||||
{method: 'UPI', share: 58},
|
||||
{method: 'Card', share: 24},
|
||||
{method: 'Cash', share: 14},
|
||||
{method: 'Wallet', share: 4},
|
||||
];
|
||||
240
src/features/commerce/services/salesAnalysis.ts
Normal file
240
src/features/commerce/services/salesAnalysis.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The Sales screen's numbers, derived from ONE source: the sale list for the
|
||||
* selected store and window (GET /api/sales). The cards, the trend chart and
|
||||
* the recent-sales list all read the same rows, so they cannot disagree.
|
||||
*
|
||||
* Money stays integer PAISE throughout — see types/sale.ts.
|
||||
*
|
||||
* ── Customers ────────────────────────────────────────────────────────────
|
||||
* Counted by `customerRef`, the platform's permanent visitor reference, so a
|
||||
* regular who bought three times is one customer. A sale with no identified
|
||||
* customer adds to Sales and Purchases but not to Customers — it cannot be
|
||||
* told apart from any other anonymous sale.
|
||||
*
|
||||
* ── Days ─────────────────────────────────────────────────────────────────
|
||||
* A sale's `at` is an instant. It is placed on a calendar day (and hour) in
|
||||
* the shop's timezone (DEFAULT_TZ), never the viewer's — a sale at 23:30 IST
|
||||
* is that day's trade wherever the owner is. The window's days come from the
|
||||
* same `toReportWindow` the server filtered with, so the chart's x-axis and
|
||||
* the rows can never cover different days.
|
||||
*/
|
||||
|
||||
import {DEFAULT_TZ, shiftDay, toReportWindow} from '@/services/api/range';
|
||||
import type {RangeKey} from '@/shared/types/api';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: DEFAULT_TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const HOUR_FMT = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: DEFAULT_TZ,
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** "2026-09-27" — the sale's calendar day in the shop's zone. */
|
||||
export function shopDay(iso: string): string {
|
||||
return DAY_FMT.format(new Date(iso));
|
||||
}
|
||||
|
||||
function shopHour(iso: string): number {
|
||||
return Number(HOUR_FMT.format(new Date(iso)));
|
||||
}
|
||||
|
||||
/** "Sep 25" from "2026-09-25", by string — never through a Date and a zone. */
|
||||
export function dayLabel(day: string): string {
|
||||
const [, m, d] = day.split('-');
|
||||
return `${MONTHS[Number(m) - 1]} ${d}`;
|
||||
}
|
||||
|
||||
/** Only completed trade counts toward a total — a voided sale never happened. */
|
||||
function counts(s: Sale): boolean {
|
||||
return s.status !== 'void';
|
||||
}
|
||||
|
||||
export interface Window {
|
||||
range: RangeKey;
|
||||
custom: {from: string; to: string} | null;
|
||||
/** The server's clock (response `meta.generatedAt`), never the device's. */
|
||||
serverNow: string;
|
||||
}
|
||||
|
||||
/** Every calendar day of the window, oldest first. */
|
||||
function windowDays({range, custom, serverNow}: Window): string[] {
|
||||
const {from, to} = toReportWindow(range, new Date(serverNow), undefined, custom);
|
||||
const out: string[] = [];
|
||||
for (let d = from; d <= to; d = shiftDay(d, 1)) out.push(d);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SalesSummary {
|
||||
salesPaise: number;
|
||||
purchases: number;
|
||||
/** Null when there were no purchases — never a division by zero. */
|
||||
averagePaise: number | null;
|
||||
customers: number;
|
||||
/** Per-day series across the window, for each card's sparkline. */
|
||||
trend: {
|
||||
sales: {v: number}[];
|
||||
purchases: {v: number}[];
|
||||
/** Only days that had a sale: a day with none has no average. */
|
||||
average: {v: number}[];
|
||||
customers: {v: number}[];
|
||||
};
|
||||
}
|
||||
|
||||
export function summarise(sales: Sale[], window: Window): SalesSummary {
|
||||
const days = new Map(
|
||||
windowDays(window).map((d) => [
|
||||
d,
|
||||
{paise: 0, purchases: 0, customers: new Set<string>()},
|
||||
]),
|
||||
);
|
||||
const customers = new Set<string>();
|
||||
let salesPaise = 0;
|
||||
let purchases = 0;
|
||||
|
||||
for (const s of sales) {
|
||||
if (!counts(s)) continue;
|
||||
salesPaise += s.totalPaise;
|
||||
purchases += 1;
|
||||
if (s.customerRef) customers.add(s.customerRef);
|
||||
const day = days.get(shopDay(s.at));
|
||||
if (day) {
|
||||
day.paise += s.totalPaise;
|
||||
day.purchases += 1;
|
||||
if (s.customerRef) day.customers.add(s.customerRef);
|
||||
}
|
||||
}
|
||||
|
||||
const perDay = [...days.values()];
|
||||
return {
|
||||
salesPaise,
|
||||
purchases,
|
||||
averagePaise: purchases > 0 ? Math.round(salesPaise / purchases) : null,
|
||||
customers: customers.size,
|
||||
trend: {
|
||||
sales: perDay.map((d) => ({v: d.paise})),
|
||||
purchases: perDay.map((d) => ({v: d.purchases})),
|
||||
average: perDay
|
||||
.filter((d) => d.purchases > 0)
|
||||
.map((d) => ({v: Math.round(d.paise / d.purchases)})),
|
||||
customers: perDay.map((d) => ({v: d.customers.size})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Total of completed sales — the Sales card's "Today" line. */
|
||||
export function totalPaise(sales: Sale[]): number {
|
||||
return sales.reduce((sum, s) => (counts(s) ? sum + s.totalPaise : sum), 0);
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
/** Axis label: "Sep 25", or "14:00" when the window is today. */
|
||||
label: string;
|
||||
/** Rupees, for the axis and chart. Totals stay paise until this point. */
|
||||
sales: number;
|
||||
purchases: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One point per day of the window — or, for Today, one per hour up to now,
|
||||
* since a single-day window as a single point is not a trend. Days with no
|
||||
* sales are zero points, so a quiet day reads as a dip rather than vanishing.
|
||||
*/
|
||||
export function salesTrend(sales: Sale[], window: Window): TrendPoint[] {
|
||||
if (window.range === 'today') {
|
||||
const nowHour = shopHour(window.serverNow);
|
||||
const hours = Array.from({length: nowHour + 1}, (_, h) => ({
|
||||
label: `${String(h).padStart(2, '0')}:00`,
|
||||
paise: 0,
|
||||
purchases: 0,
|
||||
}));
|
||||
for (const s of sales) {
|
||||
if (!counts(s)) continue;
|
||||
const h = hours[shopHour(s.at)];
|
||||
if (!h) continue;
|
||||
h.paise += s.totalPaise;
|
||||
h.purchases += 1;
|
||||
}
|
||||
return hours.map((h) => ({label: h.label, sales: h.paise / 100, purchases: h.purchases}));
|
||||
}
|
||||
|
||||
const days = new Map(
|
||||
windowDays(window).map((d) => [d, {paise: 0, purchases: 0}]),
|
||||
);
|
||||
for (const s of sales) {
|
||||
if (!counts(s)) continue;
|
||||
const d = days.get(shopDay(s.at));
|
||||
if (!d) continue;
|
||||
d.paise += s.totalPaise;
|
||||
d.purchases += 1;
|
||||
}
|
||||
return [...days.entries()].map(([day, d]) => ({
|
||||
label: dayLabel(day),
|
||||
sales: d.paise / 100,
|
||||
purchases: d.purchases,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface OutletTotal {
|
||||
siteId: string;
|
||||
paise: number;
|
||||
purchases: number;
|
||||
}
|
||||
|
||||
/** Sales per shop, highest first — the real "Top performing outlets". */
|
||||
export function byOutlet(sales: Sale[]): OutletTotal[] {
|
||||
const out = new Map<string, OutletTotal>();
|
||||
for (const s of sales) {
|
||||
if (!counts(s)) continue;
|
||||
const o = out.get(s.siteId) ?? {siteId: s.siteId, paise: 0, purchases: 0};
|
||||
o.paise += s.totalPaise;
|
||||
o.purchases += 1;
|
||||
out.set(s.siteId, o);
|
||||
}
|
||||
return [...out.values()].sort((a, b) => b.paise - a.paise);
|
||||
}
|
||||
|
||||
export interface SalesFacts {
|
||||
/** Busiest hour of day (0–23, shop time) and its share of sales, 0–100. */
|
||||
peakHour: {hour: number; pct: number} | null;
|
||||
/** Lines customers asked about but did not buy, and lines they bought. */
|
||||
enquiries: number;
|
||||
purchasedLines: number;
|
||||
/** The largest single sale in the window. */
|
||||
largest: Sale | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw material for "Sales insights" — every sentence on that panel is
|
||||
* computed from here, so none of it is copy that could go stale.
|
||||
*/
|
||||
export function salesFacts(sales: Sale[]): SalesFacts {
|
||||
const hours = new Array<number>(24).fill(0);
|
||||
let total = 0;
|
||||
let enquiries = 0;
|
||||
let purchasedLines = 0;
|
||||
let largest: Sale | null = null;
|
||||
|
||||
for (const s of sales) {
|
||||
if (!counts(s)) continue;
|
||||
hours[shopHour(s.at)] += s.totalPaise;
|
||||
total += s.totalPaise;
|
||||
enquiries += s.enquiryLines;
|
||||
purchasedLines += s.purchasedLines;
|
||||
if (!largest || s.totalPaise > largest.totalPaise) largest = s;
|
||||
}
|
||||
|
||||
const peak = hours.reduce((best, v, h) => (v > hours[best] ? h : best), 0);
|
||||
return {
|
||||
peakHour: total > 0 ? {hour: peak, pct: Math.round((hours[peak] / total) * 100)} : null,
|
||||
enquiries,
|
||||
purchasedLines,
|
||||
largest,
|
||||
};
|
||||
}
|
||||
146
src/features/dashboard/components/ActivityCard.tsx
Normal file
146
src/features/dashboard/components/ActivityCard.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
|
||||
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
/**
|
||||
* One activity, at a weight the dashboard can afford.
|
||||
*
|
||||
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
|
||||
* rather than a white card on the page, the number is `display-3` against the
|
||||
* KPI row's own display-3 but in a card a third the height, and there is no
|
||||
* sparkline. Six activities rendered at KPI weight would out-shout the four
|
||||
* metrics that actually describe the business — the same reasoning that made
|
||||
* store comparison small multiples instead of five full charts.
|
||||
*
|
||||
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
|
||||
* "N customers → N purchases", because an activity count on its own is a
|
||||
* vanity metric: 724 spins is not a fact a merchant can do anything with until
|
||||
* they know what it was worth. `detail` opens the chain up to its full length
|
||||
* for the analytics page, where there is room for the middle of the story.
|
||||
*/
|
||||
/**
|
||||
* Status is a DOT, not a badge.
|
||||
*
|
||||
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
|
||||
* thing on a page whose subject is the numbers beside them, and a third hue
|
||||
* competing with the two the brand actually owns. An 8px dot carries the same
|
||||
* three states at a fraction of the ink, which is the project's own rule:
|
||||
* status → StatusDot, badge → counts and enumerated states.
|
||||
*/
|
||||
const STATUS: Record<
|
||||
ActivityMetric['status'],
|
||||
{label: string; variant: 'success' | 'warning' | 'neutral'}
|
||||
> = {
|
||||
live: {label: 'Live', variant: 'success'},
|
||||
paused: {label: 'Paused', variant: 'warning'},
|
||||
draft: {label: 'Draft', variant: 'neutral'},
|
||||
};
|
||||
|
||||
export function ActivityCard({
|
||||
metric,
|
||||
variant = 'summary',
|
||||
}: {
|
||||
metric: ActivityMetric;
|
||||
/**
|
||||
* `summary` — the dashboard's six. Count, delta, and the two ends of the
|
||||
* impact chain. No status: a summary of six live activities does not need
|
||||
* six "Live" badges competing with the numbers.
|
||||
*
|
||||
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
|
||||
* rather than reads: whether it is running, what it costs in LYTs, and the
|
||||
* full chain including reward claims and repeat visits.
|
||||
*/
|
||||
variant?: 'summary' | 'catalogue';
|
||||
}) {
|
||||
const accent = ACCENT[metric.accent];
|
||||
const isCatalogue = variant === 'catalogue';
|
||||
const chain = impactChain(metric, {compact: !isCatalogue});
|
||||
const status = STATUS[metric.status];
|
||||
|
||||
// `card-nested` keeps the dark theme's raised fill and gives the light theme
|
||||
// a white surface with a hairline edge instead — see globals.css.
|
||||
return (
|
||||
<Card variant="muted" className="card-nested">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<HStack gap={2} vAlign="center" className="min-w-0">
|
||||
{/*
|
||||
The only place the brand hue appears on this card. A tinted chip
|
||||
at 16px is enough to identify the activity at a glance; tinting
|
||||
the card itself is how a grid of six turns into a paint chart.
|
||||
*/}
|
||||
<HStack
|
||||
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
|
||||
vAlign="center"
|
||||
>
|
||||
<Icon
|
||||
icon={ACTIVITY_ICON[metric.id]}
|
||||
size="sm"
|
||||
className={accent.ink}
|
||||
/>
|
||||
</HStack>
|
||||
<Text size="sm" weight="medium" className="truncate">
|
||||
{metric.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
{isCatalogue ? (
|
||||
<HStack gap={1.5} vAlign="center" className="shrink-0">
|
||||
<StatusDot variant={status.variant} label={status.label} />
|
||||
<Text size="xsm" color="secondary">
|
||||
{status.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<MetricDelta value={metric.deltaPct} size="xsm" />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Data, not a section title — Text rather than Heading, so a grid of
|
||||
activity counts stays out of the document outline. */}
|
||||
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
|
||||
<Text type="display-3">{formatCompact(metric.count)}</Text>
|
||||
{/* The catalogue traded its delta for the status badge above, so the
|
||||
trend comes back down here — a merchant deciding whether to keep
|
||||
an activity running needs both. */}
|
||||
{isCatalogue ? (
|
||||
<MetricDelta value={metric.deltaPct} size="xsm" />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{isCatalogue ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{metric.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
{chain
|
||||
.map((s) => `${formatCount(s.value)} ${s.label}`)
|
||||
.join(' → ')}
|
||||
</Text>
|
||||
|
||||
{/* The LYT link, and the one number on this card that is MEASURED
|
||||
rather than attributed — the ledger knows what it issued. Omitted
|
||||
where the activity grants nothing, rather than shown as zero. */}
|
||||
{isCatalogue && metric.lytsIssued > 0 ? (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatLyt(metric.lytsIssued)} issued
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
68
src/features/dashboard/components/ActivityGroups.tsx
Normal file
68
src/features/dashboard/components/ActivityGroups.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ActivityCard} from './ActivityCard';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The complete ecosystem, grouped by the question each group answers.
|
||||
*
|
||||
* Ten activities laid out as one flat grid is a list of features. Split three
|
||||
* ways it becomes a diagnosis: engagement says what customers are doing,
|
||||
* growth says what is bringing people in, commerce says whether either turned
|
||||
* into business. A merchant with a flat conversion number and a rising
|
||||
* engagement number knows which of the three to look at.
|
||||
*
|
||||
* One resource, three panels. The panels share a single fetch, so the groups
|
||||
* can never disagree about a total, and a group with nothing in it renders its
|
||||
* own empty state rather than leaving a headed card with a void under it.
|
||||
*/
|
||||
export function ActivityGroups({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityMetric[]>;
|
||||
}) {
|
||||
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
{ACTIVITY_GROUPS.map((group) => (
|
||||
<PanelCard
|
||||
key={group.id}
|
||||
title={group.label}
|
||||
subtitle={group.question}
|
||||
actions={<AttributionNote basis={basis} />}
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="activityVisit"
|
||||
title={`No ${group.label.toLowerCase()} activity`}
|
||||
description="Activities appear here once customers start taking part."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(metrics) => (
|
||||
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
|
||||
{metrics
|
||||
.filter((m) => m.group === group.id)
|
||||
.map((m) => (
|
||||
// `catalogue` — this is the management view, so each card
|
||||
// adds what the dashboard summary has no room for: status,
|
||||
// LYTs issued, and the full impact chain.
|
||||
<ActivityCard key={m.id} metric={m} variant="catalogue" />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</PanelCard>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal file
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
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 {ACCENT} from '@/shared/utils/accent';
|
||||
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||
import {
|
||||
ACTIVITY_ICON,
|
||||
conversionPct,
|
||||
} from '@/features/dashboard/services/activityService';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Every activity's chain, side by side.
|
||||
*
|
||||
* The cards above answer "how is Spin doing?". This answers the question they
|
||||
* cannot: "which activity is worth running?" — and that is a comparison across
|
||||
* rows, which is a table. Ten small charts would say the same thing in ten
|
||||
* times the space and still not let anyone rank the last column.
|
||||
*
|
||||
* Rows are ordered by the END of the chain, not the start. Sorting by
|
||||
* interactions puts Walk on top, which is the activity that converts worst;
|
||||
* sorting by attributed revenue puts the table in the order a merchant would
|
||||
* spend their next hour in.
|
||||
*/
|
||||
|
||||
/** A flattened row. The table generic needs an index signature; the domain
|
||||
* type deliberately does not have one. */
|
||||
interface ImpactRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
accent: ActivityMetric['accent'];
|
||||
activityId: ActivityMetric['id'];
|
||||
count: number;
|
||||
purchases: number;
|
||||
conversion: number;
|
||||
revenueInr: number;
|
||||
}
|
||||
|
||||
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
|
||||
return metrics
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
accent: m.accent,
|
||||
activityId: m.id,
|
||||
count: m.count,
|
||||
purchases: m.impact.purchases,
|
||||
conversion: conversionPct(m),
|
||||
revenueInr: m.impact.attributedRevenueInr,
|
||||
}))
|
||||
.sort((a, b) => b.revenueInr - a.revenueInr);
|
||||
}
|
||||
|
||||
const num = (v: number) => (
|
||||
<Text size="sm" className="tabular-nums">
|
||||
{formatCount(v)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const COLUMNS: TableColumn<ImpactRow>[] = [
|
||||
{
|
||||
key: 'label',
|
||||
header: 'Activity',
|
||||
width: proportional(1),
|
||||
// Label only. The one-line description belongs on the cards above, where
|
||||
// there is width for it; in a cell 120px wide it wrapped to four lines and
|
||||
// tripled the height of every row in a table whose whole job is to be
|
||||
// scanned down a column.
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Icon
|
||||
icon={ACTIVITY_ICON[row.activityId]}
|
||||
size="sm"
|
||||
className={ACCENT[row.accent].ink}
|
||||
/>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
|
||||
// so a header longer than its column is silently truncated to "Interacti…"
|
||||
// at EVERY width, because these columns are fixed px. The subtitle already
|
||||
// says what is being counted; the header only has to say which column it
|
||||
// is, and a short one lets the whole set fit the 525px the panel gives it
|
||||
// with Loyaly AI open.
|
||||
header: 'Count',
|
||||
align: 'end',
|
||||
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
|
||||
// is ~625px — enough width that the LAST column, the attributed revenue
|
||||
// this table is sorted by, fell off the edge behind an internal scrollbar.
|
||||
// The trend is already on every card above; the ranking is only here.
|
||||
width: pixel(80),
|
||||
renderCell: (row) => num(row.count),
|
||||
},
|
||||
/*
|
||||
* FIVE columns, and the number is measured rather than chosen. Inside a
|
||||
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
|
||||
* each side, plus the 24/32px cell inset contract from globals.css. Six
|
||||
* columns needed 580 and pushed `Attributed` — the column the table is
|
||||
* SORTED by — behind an internal scrollbar, which makes the ranking
|
||||
* invisible at exactly the width most merchants use.
|
||||
*
|
||||
* So the two intermediate chain steps are dropped here: customers and
|
||||
* repeat visits are on every card above and in the phone fallback. What
|
||||
* survives is what ranking needs — how much happened, what it produced,
|
||||
* how efficiently, and what it was worth.
|
||||
*/
|
||||
{
|
||||
key: 'purchases',
|
||||
header: 'Purchases',
|
||||
align: 'end',
|
||||
width: pixel(105),
|
||||
renderCell: (row) => num(row.purchases),
|
||||
},
|
||||
{
|
||||
key: 'conversion',
|
||||
header: 'Converted',
|
||||
align: 'end',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium" className="tabular-nums">
|
||||
{formatPct(row.conversion, 0)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'revenueInr',
|
||||
// "Revenue" would be a claim this data cannot support. The column ranks
|
||||
// the table, so it is the one header that most needs to be honest.
|
||||
header: 'Attributed',
|
||||
align: 'end',
|
||||
width: pixel(112),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="semibold" className="tabular-nums">
|
||||
{formatInrCompact(row.revenueInr)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function ActivityImpactTable({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityMetric[]>;
|
||||
}) {
|
||||
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Activity impact"
|
||||
subtitle="Interactions through to estimated attributed revenue, highest first"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={44} />}
|
||||
actions={<AttributionNote basis={basis} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="analytics"
|
||||
title="Nothing to compare yet"
|
||||
description="Once two or more activities have run, their conversion can be ranked here."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(metrics) => (
|
||||
<ResponsiveTable
|
||||
data={toRows(metrics)}
|
||||
columns={COLUMNS}
|
||||
idKey="id"
|
||||
primaryKey="label"
|
||||
density="balanced"
|
||||
// Conversion earns its place on the phone card: it is the one field
|
||||
// that ranks activities against each other, which is the whole
|
||||
// reason this table exists. Repeat visits is the one dropped.
|
||||
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
109
src/features/dashboard/components/ActivityTimeline.tsx
Normal file
109
src/features/dashboard/components/ActivityTimeline.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ActivityFeed} from '@/shared/components/patterns/ActivityItem';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import type {ActivityEntry, ActivityTone} from '@/shared/components/patterns/ActivityItem';
|
||||
import type {ActivityEvent, ActivityKind} from '@/features/dashboard/types/dashboard';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Each event kind gets its own glyph, so the feed can be skimmed by category
|
||||
* without reading a word of it.
|
||||
*
|
||||
* Only one kind carries semantic colour. An expiry is the single event here a
|
||||
* merchant may need to act on; redemptions, purchases, check-ins and openings
|
||||
* are the shop working normally, and colouring them would make the one thing
|
||||
* that matters harder to find.
|
||||
*/
|
||||
const KIND: Record<
|
||||
ActivityKind,
|
||||
{icon: IconKey; tone: ActivityTone; toneLabel: string}
|
||||
> = {
|
||||
purchase: {icon: 'purchases', tone: 'neutral', toneLabel: 'Purchase'},
|
||||
reward_redeemed: {icon: 'lyts', tone: 'neutral', toneLabel: 'Reward'},
|
||||
staff_checked_in: {icon: 'staff', tone: 'neutral', toneLabel: 'Staff'},
|
||||
store_opened: {icon: 'stores', tone: 'neutral', toneLabel: 'Store'},
|
||||
reward_expired: {icon: 'alert', tone: 'warning', toneLabel: 'Alert'},
|
||||
};
|
||||
|
||||
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
at: e.at,
|
||||
...KIND[e.kind],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed panel, in two sizes.
|
||||
*
|
||||
* Bounded by default: `limit` caps the rows and `height` caps the box, because
|
||||
* an uncapped feed is the one panel on a dashboard that grows without limit as
|
||||
* the business gets busier. It reached 784px — taller than any chart on the
|
||||
* page — which is how an operational log ends up outweighing the insights it
|
||||
* was meant to support.
|
||||
*
|
||||
* Pass `viewAllHref` and the header offers the full history instead. Pass
|
||||
* neither and it renders everything, which is what the Activity page wants.
|
||||
*/
|
||||
export function ActivityTimeline({
|
||||
resource,
|
||||
title = 'Recent activity',
|
||||
subtitle = 'Live feed across the selected store and period',
|
||||
limit,
|
||||
height,
|
||||
viewAllHref,
|
||||
}: {
|
||||
resource: Resource<ActivityEvent[]>;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Rows to show. Omit for the complete history. */
|
||||
limit?: number;
|
||||
/** Fixed content height in px. The list scrolls inside it rather than pushing
|
||||
* the card taller, so the panel's footprint is the same on every load. */
|
||||
height?: number;
|
||||
viewAllHref?: string;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={limit ?? 6} />}
|
||||
actions={
|
||||
viewAllHref ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="View all"
|
||||
href={viewAllHref}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="alert"
|
||||
title="No activity yet"
|
||||
description="Events appear here as customers redeem rewards and staff check in."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(events) =>
|
||||
height ? (
|
||||
<VStack height={height} isScrollable>
|
||||
<ActivityFeed entries={toEntries(events)} limit={limit} />
|
||||
</VStack>
|
||||
) : (
|
||||
<ActivityFeed entries={toEntries(events)} limit={limit} />
|
||||
)
|
||||
}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
44
src/features/dashboard/components/AttributionNote.tsx
Normal file
44
src/features/dashboard/components/AttributionNote.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
||||
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
|
||||
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
/**
|
||||
* The disclosure that has to sit beside any attributed figure.
|
||||
*
|
||||
* Every downstream number in the activity chain — repeat visits, purchases,
|
||||
* revenue — is MODELLED from observed footfall and purchase patterns. None of
|
||||
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
|
||||
* to "Selfie" without saying so invites a merchant to read it as "selfies
|
||||
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
|
||||
* product when the till disagrees.
|
||||
*
|
||||
* So the panels that show attributed figures carry this mark, and the copy
|
||||
* around them says "attributed", never "generated" or "earned".
|
||||
*
|
||||
* It renders NOTHING when the basis is `'observed'`. That is the point of the
|
||||
* flag: when a backend arrives that can join purchases to activity events, the
|
||||
* disclosure retires itself with no copy edit and no component removal.
|
||||
*
|
||||
* A quiet secondary glyph, not a warning — this is a footnote about method,
|
||||
* and an amber icon would rank it above the data it annotates.
|
||||
*/
|
||||
export function AttributionNote({basis}: {basis: AttributionBasis}) {
|
||||
if (basis === 'observed') return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={ATTRIBUTION_NOTE}>
|
||||
{/*
|
||||
Focusable, so the note is reachable by keyboard and not only by hover —
|
||||
it is the only place the estimation is explained, which makes it
|
||||
content rather than decoration.
|
||||
*/}
|
||||
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
|
||||
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {AXIS_TICK} from '@/shared/components/charts/ChartFrame';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {ACCENT, type BrandAccent} from '@/shared/utils/accent';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import type {
|
||||
ConversionReport,
|
||||
@@ -20,17 +13,17 @@ import type {
|
||||
interface FlowStep {
|
||||
name: string;
|
||||
value: number;
|
||||
caption: string;
|
||||
color: string;
|
||||
/** Share of everyone who came in, 0–100. Null for the base step itself. */
|
||||
pct: number | null;
|
||||
accent: BrandAccent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visitors → Returning → Purchased, as step bars against one shared scale.
|
||||
* Visitors → Returning → Purchased, each against one shared base.
|
||||
*
|
||||
* Each bar is a share of everyone who came in, so the drop between steps reads
|
||||
* at a glance. Every figure comes straight off the reports. "Purchased" is
|
||||
* purchases clamped to visitors: purchases count transactions, and a regular
|
||||
* who bought twice would otherwise out-run the visitor bar.
|
||||
* Every figure comes straight off the reports. "Purchased" is purchases
|
||||
* clamped to visitors: purchases count transactions, and a regular who bought
|
||||
* twice would otherwise out-run the visitor count.
|
||||
*/
|
||||
export function buildFlow(
|
||||
footfall: FootfallReport,
|
||||
@@ -41,26 +34,19 @@ export function buildFlow(
|
||||
footfall.newVisitors ?? footfall.buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
const returning = footfall.returningVisitors ?? Math.max(0, total - fresh);
|
||||
const purchased = Math.min(conversion.purchases, total);
|
||||
const share = (v: number) =>
|
||||
total > 0 ? ` · ${Math.round((v / total) * 100)}%` : '';
|
||||
const share = (v: number) => (total > 0 ? Math.round((v / total) * 100) : 0);
|
||||
|
||||
return [
|
||||
{name: 'Visitors', value: total, caption: formatCompact(total), color: CHART.brand.cool},
|
||||
{
|
||||
name: 'Returning',
|
||||
value: returning,
|
||||
caption: formatCompact(returning) + share(returning),
|
||||
color: CHART.brand.cool,
|
||||
},
|
||||
{
|
||||
name: 'Purchased',
|
||||
value: purchased,
|
||||
caption: formatCompact(purchased) + share(purchased),
|
||||
color: CHART.brand.warm,
|
||||
},
|
||||
{name: 'Visitors', value: total, pct: null, accent: 'cool'},
|
||||
{name: 'Returning', value: returning, pct: share(returning), accent: 'cool'},
|
||||
{name: 'Purchased', value: purchased, pct: share(purchased), accent: 'warm'},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Three stat tiles: the count, its share of visitors, and a thin bar at that
|
||||
* share. Visitors is the base, so its bar is always full.
|
||||
*/
|
||||
export function CustomerFlowChart({
|
||||
footfall,
|
||||
conversion,
|
||||
@@ -71,43 +57,30 @@ export function CustomerFlowChart({
|
||||
height?: number;
|
||||
}) {
|
||||
const steps = buildFlow(footfall, conversion);
|
||||
const max = Math.max(1, footfall.total);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart
|
||||
data={steps}
|
||||
layout="vertical"
|
||||
margin={{top: 8, right: 72, bottom: 8, left: 0}}
|
||||
barCategoryGap="28%"
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, max]} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={84}
|
||||
tick={{...AXIS_TICK, fontSize: 12, fill: 'var(--color-text-secondary)'}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={6}
|
||||
background={{fill: 'var(--color-overlay-hover)', radius: 6}}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{steps.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="caption"
|
||||
position="right"
|
||||
offset={10}
|
||||
fontSize={12}
|
||||
fill="var(--color-text-primary)"
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<VStack height={height} vAlign="center">
|
||||
<Grid columns={3} gap={6}>
|
||||
{steps.map((s) => (
|
||||
<VStack key={s.name} gap={2}>
|
||||
<Text size="sm" weight="medium" color="secondary">
|
||||
{s.name}
|
||||
</Text>
|
||||
<Text size="2xl" weight="bold">
|
||||
{formatCompact(s.value)}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.pct === null ? 'Unique people' : `${s.pct}% of visitors`}
|
||||
</Text>
|
||||
<HStack width="100%" className="h-1.5 overflow-hidden rounded-full bg-gray-subtle">
|
||||
<HStack
|
||||
width={`${s.pct ?? 100}%`}
|
||||
className={`rounded-full ${ACCENT[s.accent].solid}`}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/features/dashboard/components/PerformancePanel.tsx
Normal file
64
src/features/dashboard/components/PerformancePanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {useDashboardPerformance} from '@/features/dashboard/hooks/useDashboard';
|
||||
import type {Granularity} from '@/features/dashboard/types/dashboard';
|
||||
import {formatInrCompact} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* Weekly / monthly rollup.
|
||||
*
|
||||
* The granularity is part of the request rather than a client-side regroup:
|
||||
* the buckets have to come from the same generator the daily series does, or
|
||||
* the weekly totals quietly stop matching the daily chart above them.
|
||||
*/
|
||||
export function PerformancePanel({
|
||||
granularity,
|
||||
onGranularityChange,
|
||||
}: {
|
||||
granularity: Granularity;
|
||||
onGranularityChange: (g: Granularity) => void;
|
||||
}) {
|
||||
// Scope comes from the workspace via the hook rather than from a prop: the
|
||||
// panel is always showing the same store and period as the page around it,
|
||||
// and threading that through as an argument only created a way for them to
|
||||
// disagree.
|
||||
const resource = useDashboardPerformance(granularity);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Performance"
|
||||
subtitle={
|
||||
granularity === 'weekly'
|
||||
? 'Revenue by ISO week'
|
||||
: 'Revenue by calendar month'
|
||||
}
|
||||
resource={resource}
|
||||
actions={
|
||||
<SegmentedControl
|
||||
value={granularity}
|
||||
onChange={(v) => onGranularityChange(v as Granularity)}
|
||||
label="Performance granularity"
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="weekly" label="Weekly" />
|
||||
<SegmentedControlItem value="monthly" label="Monthly" />
|
||||
</SegmentedControl>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={rows}
|
||||
xKey="label"
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
41
src/features/dashboard/components/RewardUsageChart.tsx
Normal file
41
src/features/dashboard/components/RewardUsageChart.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import type {RewardUsagePoint} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Claimed vs used, per reward.
|
||||
*
|
||||
* The gap between the two bars is the whole point: a reward claimed 700 times
|
||||
* and redeemed 130 is an outstanding liability and a broken offer, not a
|
||||
* success. Plotting the usage *rate* alone would hide the volume; plotting
|
||||
* volume alone would hide the failure. So both bars, side by side.
|
||||
*/
|
||||
export function RewardUsageChart({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<RewardUsagePoint[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Reward usage"
|
||||
subtitle="Claimed vs actually redeemed — the gap is unspent liability"
|
||||
resource={resource}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={rows}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'claimed', label: 'Claimed'},
|
||||
{key: 'used', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
78
src/features/dashboard/components/StoreComparison.tsx
Normal file
78
src/features/dashboard/components/StoreComparison.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Sparkline} from '@/shared/components/charts/Sparkline';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {StoreComparison as StoreComparisonRow} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Small multiples, not a five-series chart.
|
||||
*
|
||||
* This is the palette constraint doing its job: five stores on one axis would
|
||||
* need five colours, and the monochrome ramp only separates about three. One
|
||||
* card per store is also simply better for the question being asked — "which
|
||||
* store is the outlier?" is a comparison of shapes, not of overlapping lines.
|
||||
*/
|
||||
export function StoreComparisonPanel({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<StoreComparisonRow[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Store comparison"
|
||||
subtitle="One card per store — five series on one axis would need five colours"
|
||||
resource={resource}
|
||||
height={220}
|
||||
>
|
||||
{(rows) => {
|
||||
const best = rows.reduce((a, b) => (b.visitors > a.visitors ? b : a));
|
||||
const worst = rows.reduce((a, b) => (b.visitors < a.visitors ? b : a));
|
||||
|
||||
return (
|
||||
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
|
||||
{rows.map((r) => (
|
||||
<Card key={r.storeId} variant="muted">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" weight="medium">
|
||||
{r.name}
|
||||
</Text>
|
||||
{r.storeId === best.storeId ? (
|
||||
<Badge variant="success" label="Top" />
|
||||
) : r.storeId === worst.storeId ? (
|
||||
<Badge variant="warning" label="Lowest" />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{/* A visitor count is data, not a section title. */}
|
||||
<Text type="display-3">{formatCompact(r.visitors)}</Text>
|
||||
<Sparkline data={r.trend} dataKey="v" height={34} />
|
||||
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Revenue"
|
||||
value={formatInrCompact(r.revenueInr)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Conversion"
|
||||
value={formatPct(r.conversionPct)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
108
src/features/dashboard/components/StoreInsights.tsx
Normal file
108
src/features/dashboard/components/StoreInsights.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {IconColor} from '@astryxdesign/core/Icon';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* What needs your attention — findings, not a chat box.
|
||||
*
|
||||
* The thing that makes an AI panel get ignored is a paragraph of narration
|
||||
* with nothing at the end of it. So the contract here is strict: every row
|
||||
* states a number the merchant can check against the panels above, says what
|
||||
* it means, and ends in ONE action. An insight with no action does not belong
|
||||
* on this panel; it belongs in whichever chart already shows it.
|
||||
*
|
||||
* Severity earns the only colour on the row. A warning is amber because a
|
||||
* warning is one of the three semantic states; everything else is a cool-tinted
|
||||
* bulb, which is this product's mark for "the system worked this out" and is
|
||||
* the same hue the analytics carry.
|
||||
*/
|
||||
const SEVERITY: Record<
|
||||
InsightSeverity,
|
||||
{icon: IconKey; color?: IconColor; className?: string}
|
||||
> = {
|
||||
error: {icon: 'alert', color: 'error'},
|
||||
warning: {icon: 'alert', color: 'warning'},
|
||||
success: {icon: 'leaderboard', color: 'success'},
|
||||
info: {icon: 'insight', className: ACCENT.cool.ink},
|
||||
};
|
||||
|
||||
function InsightRow({insight}: {insight: Insight}) {
|
||||
const tone = SEVERITY[insight.severity];
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="start"
|
||||
paddingBlock={2}
|
||||
paddingInline={2}
|
||||
className="rounded-md transition-colors hover:bg-overlay-hover"
|
||||
>
|
||||
<HStack paddingBlock={0.5}>
|
||||
<Icon
|
||||
icon={ICONS[tone.icon]}
|
||||
size="sm"
|
||||
color={tone.color}
|
||||
className={tone.className}
|
||||
label={insight.severity}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={1} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{insight.title}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{insight.body}
|
||||
</Text>
|
||||
{insight.action ? (
|
||||
<HStack>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label={insight.action.label}
|
||||
href={insight.action.href}
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="What needs your attention"
|
||||
subtitle="Read from this period's activity, most urgent first"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={3} height={72} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="insight"
|
||||
title="Nothing needs attention"
|
||||
description="Findings appear here when an activity's performance moves enough to act on."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(insights) => (
|
||||
<VStack gap={0}>
|
||||
{insights.map((i) => (
|
||||
<InsightRow key={i.id} insight={i} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,22 @@ import {
|
||||
useFootfallReport,
|
||||
useRecentVisits,
|
||||
} from './useReports';
|
||||
import type {Kpi} from '@/features/dashboard/types/dashboard';
|
||||
import type {Granularity, Kpi} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {
|
||||
buildActivity,
|
||||
buildActivityMetrics,
|
||||
buildInsights,
|
||||
buildKpis as buildSampleKpis,
|
||||
buildPeakHours,
|
||||
buildPeriodPerformance,
|
||||
buildRewardUsage,
|
||||
buildStoreComparison,
|
||||
buildTimeseries,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Scope} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
@@ -86,3 +100,75 @@ export function useDashboardKpis(
|
||||
} as Resource<Kpi[]>;
|
||||
}, [footfall, conversion]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAMPLE panels, ported from the merchant app.
|
||||
//
|
||||
// The platform has no endpoint for any of these, so they read
|
||||
// shared/mocks/sampleData.ts locally — no fake API route. Every panel that
|
||||
// renders them sits inside a <SampleScope> and wears the "Sample data" token.
|
||||
// When a real endpoint lands: add it to a repository, swap the hook body for
|
||||
// useResource(), and delete the builder from sampleData.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SampleScopeArg = Pick<Scope, 'storeId' | 'range'>;
|
||||
|
||||
function useActiveScope(scope?: SampleScopeArg): SampleScopeArg {
|
||||
const active = useScope();
|
||||
return scope ?? active;
|
||||
}
|
||||
|
||||
export function useSampleKpis(scope?: SampleScopeArg) {
|
||||
const {storeId, range} = useActiveScope(scope);
|
||||
return useSampleResource((now) => buildSampleKpis(range, storeId, now), [range, storeId]);
|
||||
}
|
||||
|
||||
export function useDashboardTimeseries(scope?: SampleScopeArg) {
|
||||
const {storeId, range} = useActiveScope(scope);
|
||||
return useSampleResource((now) => buildTimeseries(range, storeId, now), [range, storeId]);
|
||||
}
|
||||
|
||||
export function useDashboardPeakHours(scope?: SampleScopeArg) {
|
||||
const {storeId} = useActiveScope(scope);
|
||||
return useSampleResource(() => buildPeakHours(storeId), [storeId]);
|
||||
}
|
||||
|
||||
export function useDashboardActivity(scope?: SampleScopeArg) {
|
||||
const {storeId} = useActiveScope(scope);
|
||||
return useSampleResource((now) => buildActivity(storeId, now), [storeId]);
|
||||
}
|
||||
|
||||
export function useDashboardRewardUsage(scope?: SampleScopeArg) {
|
||||
const {storeId, range} = useActiveScope(scope);
|
||||
return useSampleResource(() => buildRewardUsage(storeId, range), [storeId, range]);
|
||||
}
|
||||
|
||||
export function useDashboardStoreComparison(scope?: SampleScopeArg) {
|
||||
const {range} = useActiveScope(scope);
|
||||
return useSampleResource((now) => buildStoreComparison(range, now), [range]);
|
||||
}
|
||||
|
||||
export function useDashboardPerformance(granularity: Granularity) {
|
||||
const {storeId} = useScope();
|
||||
return useSampleResource(
|
||||
(now) => buildPeriodPerformance(granularity, storeId, now),
|
||||
[granularity, storeId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All ten activities, always. The component filters to `isFeatured`, so the
|
||||
* dashboard summary and the LYTs programme cannot report different counts.
|
||||
*/
|
||||
export function useActivityMetrics(scope?: SampleScopeArg) {
|
||||
const {storeId, range} = useActiveScope(scope);
|
||||
return useSampleResource(
|
||||
(now) => buildActivityMetrics(range, storeId, now),
|
||||
[range, storeId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useStoreInsights(scope?: SampleScopeArg) {
|
||||
const {storeId, range} = useActiveScope(scope);
|
||||
return useSampleResource((now) => buildInsights(range, storeId, now), [range, storeId]);
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the dashboard charts.
|
||||
*
|
||||
* ── Why this exists ──────────────────────────────────────────────────────
|
||||
* A new merchant's reports come back empty or near-empty, and an empty axis
|
||||
* teaches nothing about what the panel is for. Until the platform has history
|
||||
* for a shop, each chart falls back to this sample and wears a "Sample data"
|
||||
* token so it can never be mistaken for the merchant's own numbers.
|
||||
*
|
||||
* ── Rules ────────────────────────────────────────────────────────────────
|
||||
* - Real data always wins — see `shared/mocks/withSample.ts`.
|
||||
* - The KPI row never uses this — a headline number must be real or absent.
|
||||
* - Values are deterministic (no Math.random) so SSR and hydration agree.
|
||||
*
|
||||
* ── Removing it ──────────────────────────────────────────────────────────
|
||||
* See `shared/mocks/withSample.ts` — one procedure for every mock folder.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
ReportBucket,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** A smooth, repeatable wave: weekly rhythm plus a gentle upward drift. */
|
||||
function wave(i: number, base: number, amp: number): number {
|
||||
const weekly = Math.sin((i / 7) * Math.PI * 2 - 1.2);
|
||||
const drift = i * 0.012;
|
||||
const ripple = Math.sin(i * 1.7) * 0.18;
|
||||
return Math.max(0, Math.round(base * (1 + drift) + amp * (weekly + ripple)));
|
||||
}
|
||||
|
||||
function dayBuckets(days: number): ReportBucket[] {
|
||||
// Fixed anchor so the labels are stable across renders and runtimes.
|
||||
const start = Date.UTC(2026, 7, 27);
|
||||
return Array.from({length: days}, (_, i) => {
|
||||
const d = new Date(start + i * 86_400_000);
|
||||
const visitors = wave(i, 140, 42);
|
||||
const newVisitors = Math.round(visitors * (0.38 + 0.06 * Math.sin(i / 3)));
|
||||
const purchases = Math.round(visitors * (0.22 + 0.04 * Math.sin(i / 4 + 1)));
|
||||
const revenue = purchases * (620 + Math.round(90 * Math.sin(i / 5)));
|
||||
return {
|
||||
label: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, '0')}`,
|
||||
visitors,
|
||||
newVisitors,
|
||||
returningVisitors: visitors - newVisitors,
|
||||
purchases,
|
||||
revenue,
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function weekBuckets(days: ReportBucket[]): ReportBucket[] {
|
||||
const weeks: ReportBucket[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const slice = days.slice(i, i + 7);
|
||||
const sum = (k: keyof ReportBucket) =>
|
||||
slice.reduce((acc, b) => acc + (b[k] as number), 0);
|
||||
const visitors = sum('visitors');
|
||||
const purchases = sum('purchases');
|
||||
weeks.push({
|
||||
label: `W${Math.floor(i / 7) + 1}`,
|
||||
visitors,
|
||||
newVisitors: sum('newVisitors'),
|
||||
returningVisitors: sum('returningVisitors'),
|
||||
purchases,
|
||||
revenue: sum('revenue'),
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
const DAYS = dayBuckets(30);
|
||||
const WEEKS = weekBuckets(DAYS);
|
||||
|
||||
function footfallFrom(buckets: ReportBucket[]): FootfallReport {
|
||||
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
|
||||
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
// Unique people < visit events: regulars come more than once.
|
||||
const total = Math.round(visits * 0.46);
|
||||
return {
|
||||
total,
|
||||
newVisitors,
|
||||
returningVisitors: Math.max(0, Math.round(total * 0.9) - newVisitors),
|
||||
visits,
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousTotal: Math.round(total * 0.88),
|
||||
};
|
||||
}
|
||||
|
||||
function conversionFrom(buckets: ReportBucket[], total: number): ConversionReport {
|
||||
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
|
||||
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
|
||||
return {
|
||||
purchases,
|
||||
revenue,
|
||||
conversionPct: Number(((purchases / total) * 100).toFixed(1)),
|
||||
basketSize: Math.round(revenue / purchases),
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousPurchases: Math.round(purchases * 0.91),
|
||||
previousRevenue: Math.round(revenue * 0.86),
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_FOOTFALL_DAILY = footfallFrom(DAYS);
|
||||
export const MOCK_FOOTFALL_WEEKLY = footfallFrom(WEEKS);
|
||||
export const MOCK_CONVERSION_DAILY = conversionFrom(DAYS, MOCK_FOOTFALL_DAILY.total);
|
||||
export const MOCK_CONVERSION_WEEKLY = conversionFrom(WEEKS, MOCK_FOOTFALL_WEEKLY.total);
|
||||
|
||||
/** True when the report carries at least one non-zero value for `key`. */
|
||||
export function hasSignal(
|
||||
report: {buckets: ReportBucket[]} | undefined,
|
||||
key: keyof Omit<ReportBucket, 'label'>,
|
||||
): boolean {
|
||||
return !!report && report.buckets.some((b) => b[key] > 0);
|
||||
}
|
||||
|
||||
export {withSample} from '@/shared/mocks/withSample';
|
||||
100
src/features/dashboard/services/activityService.ts
Normal file
100
src/features/dashboard/services/activityService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Domain rules for the activity layer.
|
||||
*
|
||||
* Two things live here that would otherwise be duplicated in every panel that
|
||||
* renders an activity: which glyph an activity gets, and how its impact chain
|
||||
* is read out. Both are decisions about the DOMAIN, not about a layout — the
|
||||
* dashboard summary, the analytics page and a campaign row must all describe
|
||||
* Spin the same way, or the merchant is looking at three products.
|
||||
*/
|
||||
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
import type {
|
||||
ActivityGroup,
|
||||
ActivityId,
|
||||
ActivityMetric,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
|
||||
walk: ICONS.activityWalk,
|
||||
visit: ICONS.activityVisit,
|
||||
selfie: ICONS.activitySelfie,
|
||||
spin: ICONS.activitySpin,
|
||||
scratch: ICONS.activityScratch,
|
||||
brand: ICONS.activityBrand,
|
||||
challenge: ICONS.activityChallenge,
|
||||
friend: ICONS.activityFriend,
|
||||
shop: ICONS.activityShop,
|
||||
event: ICONS.activityEvent,
|
||||
};
|
||||
|
||||
/**
|
||||
* The three questions the grouping answers. The description is the point of
|
||||
* the group — a heading that only says "Engagement" tells a merchant nothing
|
||||
* they could not have guessed from the cards under it.
|
||||
*/
|
||||
export const ACTIVITY_GROUPS: {
|
||||
id: ActivityGroup;
|
||||
label: string;
|
||||
question: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'engagement',
|
||||
label: 'Engagement',
|
||||
question: 'What are customers doing in and around the store?',
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
label: 'Growth',
|
||||
question: 'What is bringing new people in?',
|
||||
},
|
||||
{
|
||||
id: 'commerce',
|
||||
label: 'Commerce',
|
||||
question: 'Is any of it converting into business?',
|
||||
},
|
||||
];
|
||||
|
||||
/** A step in the activity → customer → return → purchase chain. */
|
||||
export interface ImpactStep {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The impact chain as an ordered list, ready to render.
|
||||
*
|
||||
* `rewardClaims` is dropped rather than zeroed when an activity grants no
|
||||
* reward: a chain reading "→ 0 reward claims" states a failure where there is
|
||||
* only an absence, and a merchant reads those very differently.
|
||||
*
|
||||
* `compact` drops the middle of the chain for the dashboard's small cards,
|
||||
* which have room for the two ends of the story and not the whole of it. The
|
||||
* full chain always survives on /activity — this trims the summary, it never
|
||||
* decides what the data contains.
|
||||
*/
|
||||
export function impactChain(
|
||||
m: ActivityMetric,
|
||||
{compact = false}: {compact?: boolean} = {},
|
||||
): ImpactStep[] {
|
||||
const steps: ImpactStep[] = [
|
||||
{label: 'customers', value: m.impact.customers},
|
||||
];
|
||||
|
||||
if (!compact && m.impact.rewardClaims !== undefined) {
|
||||
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
|
||||
}
|
||||
if (!compact) {
|
||||
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
|
||||
}
|
||||
steps.push({label: 'purchases', value: m.impact.purchases});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Share of participants who ended up buying — the number the chain exists for. */
|
||||
export function conversionPct(m: ActivityMetric): number {
|
||||
if (m.impact.customers === 0) return 0;
|
||||
return (m.impact.purchases / m.impact.customers) * 100;
|
||||
}
|
||||
184
src/features/dashboard/types/intelligence.ts
Normal file
184
src/features/dashboard/types/intelligence.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Store intelligence contracts — the activity layer.
|
||||
*
|
||||
* Sits beside dashboard.ts and answers a different question. Those types
|
||||
* describe what the STORE did (visitors, revenue, conversion). These describe
|
||||
* what CUSTOMERS did, and — the part that makes it intelligence rather than a
|
||||
* leaderboard — what each of those things was worth.
|
||||
*
|
||||
* THE ONE RULE THIS FILE ENCODES
|
||||
* An activity count on its own is a vanity metric. 724 spins tells a merchant
|
||||
* nothing they can act on. So `count` never travels alone: every activity
|
||||
* carries an `ActivityImpact` describing the chain
|
||||
*
|
||||
* activity → customers → return visits → purchases
|
||||
*
|
||||
* and every surface that renders an activity is expected to show at least one
|
||||
* downstream step. The numbers behind this are fixtures today; the SHAPE is
|
||||
* the contract a real backend has to satisfy, and it is deliberately not
|
||||
* "count plus a delta" — that would let the useful half be dropped silently.
|
||||
*/
|
||||
|
||||
import type {BrandAccent} from '@/shared/utils/accent';
|
||||
|
||||
/**
|
||||
* How an impact figure was arrived at. Required, never defaulted.
|
||||
*
|
||||
* This is the most important field in the file. Today every number in the
|
||||
* chain below is MODELLED — a share of observed footfall pushed through fixed
|
||||
* conversion rates — and a modelled ₹6.5L rendered in the same type as a real
|
||||
* one is how a dashboard quietly starts lying. Making the basis part of the
|
||||
* payload means the UI can label it honestly without guessing, and a backend
|
||||
* that later joins real till receipts to real activity events flips this to
|
||||
* `'observed'` and the disclosure disappears on its own. Nothing else has to
|
||||
* change: the field names already say "attributed", not "earned".
|
||||
*
|
||||
* estimated modelled from observed activity and purchase patterns
|
||||
* observed each purchase is joined to a specific activity event
|
||||
*/
|
||||
export type AttributionBasis = 'estimated' | 'observed';
|
||||
|
||||
/**
|
||||
* The one sentence the UI shows wherever an attributed figure appears. Defined
|
||||
* once so the disclosure cannot drift between the three panels that carry it.
|
||||
*/
|
||||
export const ATTRIBUTION_NOTE =
|
||||
'Attribution is estimated from observed customer activity and purchase patterns. It shows association, not proven cause.';
|
||||
|
||||
/**
|
||||
* The complete activity ecosystem. The dashboard summarises the six marked
|
||||
* `isFeatured`; all ten live on /activity.
|
||||
*/
|
||||
export type ActivityId =
|
||||
| 'walk'
|
||||
| 'visit'
|
||||
| 'selfie'
|
||||
| 'spin'
|
||||
| 'scratch'
|
||||
| 'brand'
|
||||
| 'challenge'
|
||||
| 'friend'
|
||||
| 'shop'
|
||||
| 'event';
|
||||
|
||||
/**
|
||||
* What the merchant is asking when they look at a group.
|
||||
*
|
||||
* engagement "what are customers doing?"
|
||||
* growth "what is bringing people in?"
|
||||
* commerce "is any of it converting?"
|
||||
*/
|
||||
export type ActivityGroup = 'engagement' | 'growth' | 'commerce';
|
||||
|
||||
/**
|
||||
* The chain that connects an interaction to money.
|
||||
*
|
||||
* Read every field after `customers` as ATTRIBUTED rather than caused. A
|
||||
* customer who spun the wheel and later bought something is an association;
|
||||
* whether the spin is why they bought is not knowable from this data, and the
|
||||
* UI must not phrase it as though it were.
|
||||
*/
|
||||
export interface ActivityImpact {
|
||||
/** Distinct customers behind the interactions. Directly observed. */
|
||||
customers: number;
|
||||
/** Rewards claimed off the back of it. Absent where the activity grants none. */
|
||||
rewardClaims?: number;
|
||||
/** Participants who returned to the store within the period. */
|
||||
repeatVisits: number;
|
||||
/** Purchases by those participants, attributed to this activity. */
|
||||
purchases: number;
|
||||
/**
|
||||
* Revenue on those purchases. Named `attributed` rather than `revenue`
|
||||
* because that is what it is — see AttributionBasis.
|
||||
*/
|
||||
attributedRevenueInr: number;
|
||||
attribution: AttributionBasis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the merchant is currently running the activity.
|
||||
*
|
||||
* Lives on the shared metric rather than on a Lyts-only type: the dashboard
|
||||
* and the Lyts catalogue must never disagree about whether Spin is live, and
|
||||
* two types would guarantee that they eventually do.
|
||||
*/
|
||||
export type ActivityStatus = 'live' | 'paused' | 'draft';
|
||||
|
||||
export interface ActivityMetric {
|
||||
id: ActivityId;
|
||||
label: string;
|
||||
/** One line on what the activity is — the merchant may not have run it yet. */
|
||||
description: string;
|
||||
group: ActivityGroup;
|
||||
/**
|
||||
* Fixed per activity, carried on the payload rather than derived from grid
|
||||
* position, so an activity keeps its colour across every screen it appears
|
||||
* on and a reordered grid does not repaint.
|
||||
*/
|
||||
accent: BrandAccent;
|
||||
/** Interactions recorded in the period — the headline number. */
|
||||
count: number;
|
||||
deltaPct: number;
|
||||
/**
|
||||
* Whether the activity is running. The dashboard summary does not show this
|
||||
* — a summary of six live activities does not need six "Live" badges — but
|
||||
* the Lyts catalogue is where a merchant turns things on and off, so it is
|
||||
* the same field rather than a second source of truth.
|
||||
*/
|
||||
status: ActivityStatus;
|
||||
/**
|
||||
* LYTs issued through this activity in the period. 1 LYT = ₹1, so this is
|
||||
* simultaneously a count and the rupee liability the activity created —
|
||||
* which is what connects the activity model to the Lyts programme.
|
||||
*
|
||||
* Directly observed, unlike everything in `impact` past `customers`: the
|
||||
* ledger knows exactly how many LYTs it issued and why.
|
||||
*/
|
||||
lytsIssued: number;
|
||||
impact: ActivityImpact;
|
||||
/** Surfaced on the dashboard summary. The rest are /activity only. */
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
export type JourneyStageId =
|
||||
| 'visit'
|
||||
| 'engage'
|
||||
| 'purchase'
|
||||
| 'return'
|
||||
| 'refer';
|
||||
|
||||
export interface JourneyStage {
|
||||
id: JourneyStageId;
|
||||
label: string;
|
||||
/** Customers who reached this stage. Monotonically decreasing by definition. */
|
||||
value: number;
|
||||
/**
|
||||
* Share of the PREVIOUS stage that made it here. Undefined on the first
|
||||
* stage, which has nothing to convert from.
|
||||
*/
|
||||
conversionPct?: number;
|
||||
}
|
||||
|
||||
export type CampaignStatus = 'live' | 'ended' | 'scheduled';
|
||||
|
||||
export interface CampaignStep {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface CampaignSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The activity it runs on — gives the row its icon and accent. */
|
||||
activityId: ActivityId;
|
||||
accent: BrandAccent;
|
||||
status: CampaignStatus;
|
||||
/**
|
||||
* Participants → engagement → conversion, in order. A campaign that is not
|
||||
* a funnel is not a campaign, so this is a list rather than named fields:
|
||||
* a referral drive ends at "new customers", an event ends at "purchases".
|
||||
*/
|
||||
steps: CampaignStep[];
|
||||
attributedRevenueInr: number;
|
||||
attribution: AttributionBasis;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {AttributionNote} from '@/features/engagement/components/AttributionNote'
|
||||
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_ACTIVITIES} from '@/features/engagement/mocks/engagementMock';
|
||||
import {MOCK_ACTIVITIES} from '@/shared/mocks/sampleData';
|
||||
import type {ActivityRow} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Null is "not reported", which is not the same fact as zero. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_CAMPAIGNS} from '@/features/engagement/mocks/engagementMock';
|
||||
import {MOCK_CAMPAIGNS} from '@/shared/mocks/sampleData';
|
||||
import type {Campaign} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
|
||||
|
||||
@@ -10,7 +10,7 @@ import {formatCompact} from '@/shared/utils/format';
|
||||
import {useJourney} from '@/features/engagement/hooks/useEngagement';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_JOURNEY} from '@/features/engagement/mocks/engagementMock';
|
||||
import {MOCK_JOURNEY} from '@/shared/mocks/sampleData';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the engagement panels (activities, campaigns,
|
||||
* journey), shown only while the platform returns none for this shop.
|
||||
*
|
||||
* Attribution stays `'estimated'` on every row, so the AttributionNote still
|
||||
* renders — sample data obeys the same disclosure rule as real data.
|
||||
*
|
||||
* Removal: see `shared/mocks/withSample.ts`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
export const MOCK_ACTIVITIES: ActivityRow[] = [
|
||||
{
|
||||
id: 'sample-selfie',
|
||||
kind: 'selfie',
|
||||
name: 'Selfie wall',
|
||||
isActive: true,
|
||||
events: 412,
|
||||
participants: 318,
|
||||
anonymous: 24,
|
||||
repeatVisitors: 142,
|
||||
purchases: 96,
|
||||
attributedRevenue: 61_400,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-spin',
|
||||
kind: 'spin',
|
||||
name: 'Spin the wheel',
|
||||
isActive: true,
|
||||
events: 286,
|
||||
participants: 231,
|
||||
anonymous: 12,
|
||||
repeatVisitors: 118,
|
||||
purchases: 74,
|
||||
attributedRevenue: 44_900,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-challenge',
|
||||
kind: 'challenge',
|
||||
name: 'Weekend challenge',
|
||||
isActive: true,
|
||||
events: 138,
|
||||
participants: 102,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 61,
|
||||
purchases: 38,
|
||||
attributedRevenue: 27_300,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-referral',
|
||||
kind: 'referral',
|
||||
name: 'Refer a friend',
|
||||
isActive: false,
|
||||
events: 54,
|
||||
participants: 47,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 29,
|
||||
purchases: 18,
|
||||
attributedRevenue: 12_800,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_CAMPAIGNS: Campaign[] = [
|
||||
{
|
||||
id: 'sample-festive',
|
||||
name: 'Festive week',
|
||||
startsAt: '2026-09-15T00:00:00Z',
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
activities: ['selfie', 'spin'],
|
||||
events: 520,
|
||||
participants: 388,
|
||||
purchasers: 131,
|
||||
attributedRevenue: 82_600,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-monsoon',
|
||||
name: 'Monsoon rewards',
|
||||
startsAt: '2026-08-28T00:00:00Z',
|
||||
endsAt: '2026-09-10T00:00:00Z',
|
||||
isActive: false,
|
||||
activities: ['challenge'],
|
||||
events: 214,
|
||||
participants: 163,
|
||||
purchasers: 57,
|
||||
attributedRevenue: 34_100,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_JOURNEY: Journey = {
|
||||
stages: [
|
||||
{id: 'visit', label: 'Visited', customers: 1_240},
|
||||
{id: 'engage', label: 'Took part', customers: 562},
|
||||
{id: 'buy', label: 'Bought', customers: 298},
|
||||
{id: 'return', label: 'Came back', customers: 211},
|
||||
{id: 'refer', label: 'Referred', customers: 47},
|
||||
],
|
||||
attribution: 'estimated',
|
||||
};
|
||||
@@ -1,126 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {motion, AnimatePresence} from 'framer-motion';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {StackItem, VStack} from '@astryxdesign/core/Layout';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {Composer} from './Composer';
|
||||
import {EmptyState} from './EmptyState';
|
||||
import {SuggestionChips} from './SuggestionChips';
|
||||
import {MessageList} from './MessageList';
|
||||
|
||||
/** Readable line length for replies, and a matching composer. */
|
||||
const CONTENT_MAX = 768;
|
||||
/** Within this distance of the bottom, the view follows a streaming reply. */
|
||||
const FOLLOW_SLACK_PX = 160;
|
||||
|
||||
/**
|
||||
* ChatGPT & Gemini style two-stage chat experience:
|
||||
* The conversation body: ONE layout in every state and at every width.
|
||||
*
|
||||
* 1. STAGE 1 — Initial Minimal Landing:
|
||||
* - Vertically centered Loyaly logo mark, heading, subtitle, and composer.
|
||||
* - No suggestion chips shown initially to keep interface calm and minimal.
|
||||
* ┌──────────────────────────┐
|
||||
* │ scroll region (fills) │ landing, or the message list
|
||||
* │ │
|
||||
* ├──────────────────────────┤
|
||||
* │ composer (docked) │ never moves, never scrolls away
|
||||
* └──────────────────────────┘
|
||||
*
|
||||
* 2. STAGE 2 — Input Focused / Typing:
|
||||
* - Focusing or typing in composer smoothly animates suggestion chips into view directly BELOW the composer.
|
||||
* Why this shape, and what it replaced. The landing used to put the composer
|
||||
* INSIDE the content, after the insight cards. Its position then depended on
|
||||
* how tall the content happened to be: on a tall panel it floated mid-screen
|
||||
* over a slab of dead space, on a short phone it scrolled off the bottom.
|
||||
* The active chat docked it at the bottom, so the two states also jumped.
|
||||
* Docking it always makes the position a property of the layout, not of the
|
||||
* content, which is what holds on every device.
|
||||
*
|
||||
* 3. Active Conversation Mode:
|
||||
* - First message sent removes landing elements and morphs composer to sticky bottom.
|
||||
* `size="fill"` + `isScrollable` is a complete scroll region — StackItem
|
||||
* applies the `min-height: 0` reset that the old `h-full` child lacked (that
|
||||
* made the body a header-height taller than the panel and clipped it).
|
||||
*
|
||||
* 4. New Chat:
|
||||
* - Returns to STAGE 1 (minimal landing, chips hidden until focused again).
|
||||
* The landing centres itself vertically when there is room and scrolls from
|
||||
* the top when there is not; `minHeight="100%"` is what lets both happen.
|
||||
* Suggestion chips live on the landing only — the second copy that used to
|
||||
* appear under the composer on focus duplicated "Quick queries".
|
||||
*
|
||||
* New messages and streaming text pull the view to the bottom, but only when
|
||||
* the reader is already near it — someone who scrolled up to re-read an
|
||||
* earlier answer is left where they are.
|
||||
*/
|
||||
export function Conversation() {
|
||||
const {conversation, draft, setDraft, send, stop, isStreaming} =
|
||||
useLoyalyAi();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const {conversation, draft, setDraft, send, stop, isStreaming} = useLoyalyAi();
|
||||
const hasMessages = conversation.messages.length > 0;
|
||||
const showChips = isFocused || draft.trim().length > 0;
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const last = conversation.messages.at(-1);
|
||||
// Changes on every new message AND on every streamed chunk.
|
||||
const tail = last ? `${last.id}:${JSON.stringify(last.parts).length}` : '';
|
||||
const lastRole = last?.role;
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !tail) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
// The user's own message always scrolls into view; a reply follows only
|
||||
// if they were already reading the bottom.
|
||||
if (lastRole === 'user' || distance < FOLLOW_SLACK_PX) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [tail, lastRole]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full w-full relative overflow-hidden bg-background">
|
||||
<AnimatePresence>
|
||||
{!hasMessages ? (
|
||||
<motion.div
|
||||
key="empty-landing-view"
|
||||
initial={{opacity: 0, scale: 0.98}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.96, transition: {duration: 0.2}}}
|
||||
transition={{duration: 0.3, ease: 'easeOut'}}
|
||||
className="flex-1 flex flex-col items-center px-4 py-6 w-full max-w-2xl mx-auto overflow-y-auto"
|
||||
<StackItem size="fill" className="bg-background">
|
||||
<VStack height="100%">
|
||||
<StackItem size="fill" isScrollable ref={scrollRef}>
|
||||
<VStack
|
||||
minHeight="100%"
|
||||
vAlign={hasMessages ? 'start' : 'center'}
|
||||
paddingInline={4}
|
||||
paddingBlock={5}
|
||||
>
|
||||
<div className="w-full flex flex-col items-center py-1">
|
||||
<EmptyState onPick={send} />
|
||||
<VStack gap={4} width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
|
||||
{hasMessages ? (
|
||||
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
|
||||
) : (
|
||||
<EmptyState onPick={send} />
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</StackItem>
|
||||
|
||||
{/* Chat Composer (36px gap from subtitle) */}
|
||||
<motion.div
|
||||
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
|
||||
className="w-full max-w-xl mt-9"
|
||||
>
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* STAGE 2 — Suggestion Chips animate in BELOW the composer when focused or typing */}
|
||||
<AnimatePresence>
|
||||
{showChips && (
|
||||
<motion.div
|
||||
key="stage-2-chips"
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -8}}
|
||||
transition={{duration: 0.22, ease: 'easeOut'}}
|
||||
className="w-full max-w-xl mt-[28px]"
|
||||
>
|
||||
<SuggestionChips onPick={send} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="active-chat-view"
|
||||
/*
|
||||
* No initial/animate opacity here.
|
||||
*
|
||||
* The composer below shares a `layoutId` with the one in the empty
|
||||
* view, and framer defers an entering element's `animate` while it
|
||||
* projects a shared layout across the swap — which left this view
|
||||
* mounted at `opacity: 0` with the conversation invisible behind
|
||||
* it (measured: two messages in the DOM, blank panel). The morph
|
||||
* itself already carries the continuity this fade was for.
|
||||
*/
|
||||
className="flex-1 flex flex-col h-full w-full relative overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 w-full">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-4">
|
||||
<MessageList
|
||||
messages={conversation.messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
|
||||
className="p-3 border-t border-border bg-popover/90 backdrop-blur-md sticky bottom-0 z-10 w-full shrink-0"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<VStack
|
||||
paddingInline={3}
|
||||
paddingBlock={3}
|
||||
className="shrink-0 border-t border-border bg-surface"
|
||||
>
|
||||
<VStack width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</StackItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {motion} from 'framer-motion';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {SAMPLE_AI_INSIGHTS, SAMPLE_AI_PULSE} from '@/shared/mocks/sampleData';
|
||||
|
||||
const GREETINGS = [
|
||||
'Ready to optimize your stores?',
|
||||
'What would you like to analyze today?',
|
||||
'Need proactive retail insights?',
|
||||
'Explore your store intelligence',
|
||||
"Let's boost today's footfall & sales",
|
||||
];
|
||||
|
||||
interface InsightCardItem {
|
||||
export interface InsightCardItem {
|
||||
id: string;
|
||||
tag: string;
|
||||
tagColor: 'cool' | 'warm';
|
||||
@@ -27,147 +23,124 @@ interface InsightCardItem {
|
||||
icon: keyof typeof ICONS;
|
||||
}
|
||||
|
||||
const ACTIVE_INSIGHTS: InsightCardItem[] = [
|
||||
{
|
||||
id: 'peak-hours',
|
||||
tag: 'Traffic Optimization',
|
||||
tagColor: 'cool',
|
||||
title: 'Peak Footfall Window Detected',
|
||||
description: 'Traffic consistently surges +28% on weekends from 2 PM – 5 PM. Adjusting shop-floor staffing can increase conversion by ~14%.',
|
||||
actionText: 'Analyze Store Hours',
|
||||
prompt: 'How can I optimize store hours and staffing shifts during peak weekend footfall?',
|
||||
icon: 'activityEvent',
|
||||
},
|
||||
{
|
||||
id: 'campaign-roi',
|
||||
tag: 'Reward Performance',
|
||||
tagColor: 'warm',
|
||||
title: 'Spin & Win Loyalty Surge',
|
||||
description: '34% repeat visits recorded from the recent loyalty campaign. Attributed revenue up +₹42,000.',
|
||||
actionText: 'View Campaign ROI',
|
||||
prompt: 'Show me the performance of the Spin & Win campaign and recommend settings for this weekend.',
|
||||
icon: 'lyts',
|
||||
},
|
||||
];
|
||||
|
||||
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
|
||||
const [greeting] = useState(GREETINGS[0]);
|
||||
const SAMPLE_NOTE = 'Example figures — the assistant has no live store feed yet';
|
||||
|
||||
/** Small caps label above a group — "ACTIVE INSIGHTS". */
|
||||
function GroupLabel({icon, children}: {icon: keyof typeof ICONS; children: string}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-xl mx-auto space-y-5 pt-2">
|
||||
{/* 1. Official Loyaly Heart Logo with Glow */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="relative mb-3 flex justify-center group cursor-pointer pt-1">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-full blur-md opacity-75 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
<BrandMark size={52} priority />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
key={greeting}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.25, ease: 'easeOut'}}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-bold tracking-tight">
|
||||
{greeting}
|
||||
</Heading>
|
||||
<Text type="supporting" justify="center" className="text-xs sm:text-sm mt-1 max-w-sm">
|
||||
Your proactive AI copilot for smarter retail decisions and instant analytics.
|
||||
</Text>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 2. Live Telemetry Strip */}
|
||||
<div className="w-full flex items-center justify-between px-3.5 py-1.5 rounded-full bg-zinc-500/5 dark:bg-zinc-400/5 border border-border text-[11px] text-secondary">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Live Store Stream</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Footfall: <strong className="text-primary font-semibold">+18.4%</strong></span>
|
||||
<span>Sync: <strong className="text-emerald-500 font-semibold">99.8%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Proactive Active Insights */}
|
||||
<div className="w-full space-y-2.5 text-left">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-secondary flex items-center gap-1.5">
|
||||
<Icon icon={ICONS.insight} size="xsm" />
|
||||
Active Insights
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">Real-time</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{ACTIVE_INSIGHTS.map((item) => {
|
||||
const isCool = item.tagColor === 'cool';
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative p-3.5 rounded-xl border border-border bg-card hover:border-border-emphasized transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer"
|
||||
onClick={() => onPick?.(item.prompt)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${
|
||||
isCool
|
||||
? 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="xsm" color="inherit" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-primary truncate">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.2 rounded-full font-medium ${
|
||||
isCool
|
||||
? 'bg-violet-500/10 text-violet-600 dark:text-violet-300'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-secondary leading-relaxed line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] font-medium text-primary group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
|
||||
<span>{item.actionText}</span>
|
||||
<Icon icon={ICONS.arrowRight} size="xsm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Quick Suggestion Action Chips */}
|
||||
<div className="w-full text-left space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-secondary px-1 flex items-center gap-1.5">
|
||||
<Icon icon={ICONS.chat} size="xsm" />
|
||||
Quick Queries
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUGGESTIONS.slice(0, 5).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-xs rounded-full border border-border bg-card text-secondary hover:text-primary hover:bg-muted hover:border-border-emphasized transition-all duration-150 cursor-pointer shadow-xs active:scale-95"
|
||||
onClick={() => onPick?.(s.prompt)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Icon icon={ICONS[icon]} size="xsm" color="secondary" />
|
||||
<Text size="xsm" weight="semibold" color="secondary" className="uppercase tracking-wider">
|
||||
{children}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant's landing: greeting, store pulse, insight cards, quick
|
||||
* queries. The composer is NOT here — Conversation docks it below the scroll
|
||||
* region so it sits in the same place on every screen.
|
||||
*
|
||||
* Built from design-system components end to end so it reflows by itself:
|
||||
* cards stretch to the panel, the pulse strip and the chip row wrap on a
|
||||
* narrow phone, and nothing carries a fixed pixel size or a raw colour. The
|
||||
* two brand accents come only through ACCENT (see CLAUDE.md).
|
||||
*
|
||||
* The pulse figures and the insight cards are SAMPLE (shared/mocks/
|
||||
* sampleData.ts), and say so — they used to be labelled "Live" and
|
||||
* "Real-time" while being constants.
|
||||
*/
|
||||
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<VStack gap={2} hAlign="center">
|
||||
<BrandMark size={48} priority />
|
||||
<Heading level={2} justify="center">
|
||||
Ready to optimize your stores?
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary" justify="center">
|
||||
Your proactive AI copilot for smarter retail decisions and instant analytics.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
wrap="wrap"
|
||||
paddingInline={4}
|
||||
paddingBlock={2}
|
||||
className="rounded-full border border-border bg-surface"
|
||||
>
|
||||
<HStack gap={4} wrap="wrap">
|
||||
<Text size="sm" color="secondary">
|
||||
Footfall{' '}
|
||||
<Text type="inherit" weight="semibold" color="primary">
|
||||
+{SAMPLE_AI_PULSE.footfallDeltaPct}%
|
||||
</Text>
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Sync{' '}
|
||||
<Text type="inherit" weight="semibold" color="primary">
|
||||
{SAMPLE_AI_PULSE.syncPct}%
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
<SampleTag show description={SAMPLE_NOTE} />
|
||||
</HStack>
|
||||
|
||||
<VStack gap={3}>
|
||||
<HStack hAlign="between" vAlign="center" gap={2}>
|
||||
<GroupLabel icon="insight">Active insights</GroupLabel>
|
||||
<SampleTag show description={SAMPLE_NOTE} />
|
||||
</HStack>
|
||||
|
||||
{SAMPLE_AI_INSIGHTS.map((item) => (
|
||||
<ClickableCard
|
||||
key={item.id}
|
||||
label={`${item.title}. ${item.actionText}`}
|
||||
onClick={() => onPick?.(item.prompt)}
|
||||
padding={4}
|
||||
>
|
||||
<HStack gap={3} vAlign="start">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className={`size-8 shrink-0 rounded-md ${ACCENT[item.tagColor].chip}`}
|
||||
>
|
||||
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="sm" color="inherit" />
|
||||
</HStack>
|
||||
<VStack gap={1.5} className="min-w-0 flex-1">
|
||||
<HStack gap={2} hAlign="between" vAlign="start" wrap="wrap">
|
||||
<Text size="sm" weight="semibold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Token size="sm" label={item.tag} />
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary" className="line-clamp-3">
|
||||
{item.description}
|
||||
</Text>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{item.actionText}
|
||||
</Text>
|
||||
<Icon icon={ICONS.arrowRight} size="xsm" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
<VStack gap={3}>
|
||||
<GroupLabel icon="chat">Quick queries</GroupLabel>
|
||||
<HStack gap={2} wrap="wrap">
|
||||
{SUGGESTIONS.slice(0, 5).map((s) => (
|
||||
<Token key={s.id} label={s.label} onClick={() => onPick?.(s.prompt)} />
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
ChatMessage as ChatMessageRow,
|
||||
ChatMessageBubble,
|
||||
} from '@astryxdesign/core/Chat';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {MessageContent} from './MessageContent';
|
||||
import {TypingIndicator} from './TypingIndicator';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
@@ -39,9 +40,13 @@ export function MessageBubble({message}: {message: ChatMessage}) {
|
||||
density="compact"
|
||||
avatar={
|
||||
isAssistant ? (
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center bg-amber-500/10 border border-amber-500/25 shrink-0 mt-0.5 shadow-xs overflow-hidden">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className={`size-6 shrink-0 overflow-hidden rounded-full border border-border ${ACCENT.warm.soft}`}
|
||||
>
|
||||
<BrandMark size={16} />
|
||||
</div>
|
||||
</HStack>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||
|
||||
/**
|
||||
* The opening moves, as a wrapped row of chips.
|
||||
*
|
||||
* Chips rather than the stacked button list this replaces: six full-width
|
||||
* secondary buttons read as a menu of commands, which is what made the old
|
||||
* panel feel like a dashboard. A wrapped row of quiet pills reads as
|
||||
* suggestions — the same distinction ChatGPT, Claude and Gemini all make.
|
||||
*
|
||||
* Hand-built rather than Astryx's Token: Token is a data chip with a
|
||||
* dismiss/selected vocabulary that does not apply here, and Button carries a
|
||||
* control's weight. What is wanted is a quiet, fully-rounded, tappable label —
|
||||
* three token-backed utilities, and no component contract to fight.
|
||||
*/
|
||||
const CHIP = [
|
||||
'rounded-full border border-border bg-card',
|
||||
'px-3.5 py-2 text-sm text-secondary text-left',
|
||||
'transition-colors duration-150 cursor-pointer',
|
||||
'hover:bg-muted hover:text-primary hover:border-border-strong',
|
||||
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
|
||||
].join(' ');
|
||||
|
||||
export function SuggestionChips({onPick}: {onPick: (prompt: string) => void}) {
|
||||
return (
|
||||
<HStack gap={2} wrap="wrap" hAlign="center">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={CHIP}
|
||||
onClick={() => onPick(s.prompt)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {ICONS} from '@/shared/utils/icons';
|
||||
import type {ReportAction} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
import {exportData} from '@/shared/utils/export/exportManager';
|
||||
import {SAMPLE_AI_EXPORT_ROWS} from '@/shared/mocks/sampleData';
|
||||
|
||||
export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
|
||||
const router = useRouter();
|
||||
@@ -36,11 +37,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
|
||||
{key: 'metric', header: 'Metric Name'},
|
||||
{key: 'val', header: 'Value'},
|
||||
],
|
||||
data: [
|
||||
{metric: 'Daily Revenue', val: '₹3,40,000'},
|
||||
{metric: 'Visitors', val: '1,284'},
|
||||
{metric: 'Purchases', val: '276'},
|
||||
],
|
||||
data: SAMPLE_AI_EXPORT_ROWS,
|
||||
format: 'csv',
|
||||
});
|
||||
toast({body: '✓ CSV downloaded successfully'});
|
||||
@@ -58,11 +55,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
|
||||
{key: 'metric', header: 'Metric Name'},
|
||||
{key: 'val', header: 'Value'},
|
||||
],
|
||||
data: [
|
||||
{metric: 'Daily Revenue', val: '₹3,40,000'},
|
||||
{metric: 'Visitors', val: '1,284'},
|
||||
{metric: 'Purchases', val: '276'},
|
||||
],
|
||||
data: SAMPLE_AI_EXPORT_ROWS,
|
||||
format: 'pdf',
|
||||
});
|
||||
toast({body: '✓ PDF downloaded successfully'});
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import type {ReportInsight} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* One neutral card for every insight type. The type is carried by the Badge,
|
||||
* which resolves through the semantic tokens; the tinted blue/amber/emerald/
|
||||
* purple borders it replaced were raw hues the monochrome rule forbids, and
|
||||
* read as four different components.
|
||||
*/
|
||||
const NEUTRAL_CARD = 'border-border bg-surface';
|
||||
|
||||
export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
if (!insights || insights.length === 0) return null;
|
||||
|
||||
@@ -13,28 +21,28 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
icon: '💡',
|
||||
badgeVariant: 'info' as const,
|
||||
label: 'Opportunity',
|
||||
borderClass: 'border-blue-500/30 bg-blue-500/[0.03]',
|
||||
borderClass: NEUTRAL_CARD,
|
||||
};
|
||||
case 'risk':
|
||||
return {
|
||||
icon: '⚠️',
|
||||
badgeVariant: 'warning' as const,
|
||||
label: 'Risk Alert',
|
||||
borderClass: 'border-amber-500/30 bg-amber-500/[0.03]',
|
||||
borderClass: NEUTRAL_CARD,
|
||||
};
|
||||
case 'growth':
|
||||
return {
|
||||
icon: '📈',
|
||||
badgeVariant: 'success' as const,
|
||||
label: 'Growth Factor',
|
||||
borderClass: 'border-emerald-500/30 bg-emerald-500/[0.03]',
|
||||
borderClass: NEUTRAL_CARD,
|
||||
};
|
||||
case 'best_performer':
|
||||
return {
|
||||
icon: '🏆',
|
||||
badgeVariant: 'success' as const,
|
||||
label: 'Top Performer',
|
||||
borderClass: 'border-purple-500/30 bg-purple-500/[0.03]',
|
||||
borderClass: NEUTRAL_CARD,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -77,7 +85,7 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
|
||||
{insight.recommendedAction ? (
|
||||
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
|
||||
<span className="text-amber-300 font-bold shrink-0">Action:</span>
|
||||
<span className="text-secondary font-bold shrink-0">Action:</span>
|
||||
<span className="text-primary whitespace-normal break-words min-w-0">
|
||||
{insight.recommendedAction}
|
||||
</span>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
||||
<span
|
||||
title={kpi.trend}
|
||||
className={`text-xs font-bold truncate max-w-full min-w-0 ${
|
||||
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
|
||||
isUp ? 'text-success' : isDown ? 'text-error' : 'text-secondary'
|
||||
}`}
|
||||
>
|
||||
{kpi.trend}
|
||||
|
||||
89
src/features/lyts/components/ActivityProgramme.tsx
Normal file
89
src/features/lyts/components/ActivityProgramme.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups';
|
||||
import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable';
|
||||
import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {formatCompact, formatLyt} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* The activity ecosystem, as the Lyts page presents it.
|
||||
*
|
||||
* ── Why this lives here and not on the dashboard ──────────────────────────
|
||||
* The two pages ask different questions of the same data. The dashboard asks
|
||||
* "how are customers engaging and what is it worth?" and answers with six
|
||||
* activities and their business impact. Lyts asks "what am I running, what
|
||||
* does it cost me in LYTs, and is it working?" — which needs all ten, their
|
||||
* status, and the LYT liability each one creates.
|
||||
*
|
||||
* ── One model, not two ────────────────────────────────────────────────────
|
||||
* Everything here reads `useActivityMetrics()` — the same hook, the same
|
||||
* endpoint, the same fixtures the dashboard uses. If the dashboard says Visit
|
||||
* is 35.3k, this page says 35.3k, because there is nowhere for a second
|
||||
* number to come from. The grouped cards and the impact table are the exact
|
||||
* components the dashboard's own activity work produced, rendered in
|
||||
* `catalogue` variant rather than reimplemented.
|
||||
*
|
||||
* The summary strip is StatPairs rather than MetricCards on purpose: the KPI
|
||||
* row directly above it is already four MetricCards about LYT liability, and
|
||||
* a second row of the same object would read as eight equal headline metrics
|
||||
* on a page whose headline is the programme, not the activities.
|
||||
*/
|
||||
export function ActivityProgramme() {
|
||||
const metrics = useActivityMetrics();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Customer activities"
|
||||
subtitle="Every way a customer can earn, and what each one issues in LYTs"
|
||||
>
|
||||
{/*
|
||||
Derived at render rather than fetched as its own summary endpoint.
|
||||
A totals endpoint could disagree with the cards below it — this
|
||||
cannot, because it is the same array.
|
||||
*/}
|
||||
{metrics.status === 'success' || metrics.status === 'empty' ? (
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Live activities"
|
||||
value={`${metrics.data.filter((m) => m.status === 'live').length} of ${metrics.data.length}`}
|
||||
/>
|
||||
<StatPair
|
||||
label="Total participation"
|
||||
value={formatCompact(
|
||||
metrics.data.reduce((a, m) => a + m.count, 0),
|
||||
)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Customers reached"
|
||||
value={formatCompact(
|
||||
// The MAXIMUM, not the sum: one customer who visits, spins and
|
||||
// takes a selfie is one customer. Adding the per-activity
|
||||
// counts would claim three, and inflate "customers reached"
|
||||
// past the store's own footfall.
|
||||
metrics.data.reduce(
|
||||
(a, m) => Math.max(a, m.impact.customers),
|
||||
0,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<StatPair
|
||||
label="LYTs issued"
|
||||
value={formatLyt(
|
||||
metrics.data.reduce((a, m) => a + m.lytsIssued, 0),
|
||||
)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
) : null}
|
||||
</StaticPanel>
|
||||
|
||||
<ActivityGroups resource={metrics} />
|
||||
|
||||
<ActivityImpactTable resource={metrics} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
85
src/features/lyts/components/ExpiryAlerts.tsx
Normal file
85
src/features/lyts/components/ExpiryAlerts.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCompact, formatLyt} from '@/shared/utils/format';
|
||||
import {daysUntil} from '@/features/lyts/services/lytsService';
|
||||
import type {Reward} from '@/features/lyts/types/reward';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Rewards lapsing within a fortnight.
|
||||
*
|
||||
* This panel exists because expiry is the one thing on this page with a
|
||||
* deadline. Everything else can be read whenever; an offer expiring in two
|
||||
* days either gets extended today or it doesn't.
|
||||
*
|
||||
* Sorted soonest-first, and the description leads with outstanding liability
|
||||
* rather than claim count — the actionable number is what customers are still
|
||||
* holding, not how popular the offer was.
|
||||
*/
|
||||
const HORIZON_DAYS = 14;
|
||||
|
||||
export function ExpiryAlerts({
|
||||
resource,
|
||||
nowMs,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
nowMs: number;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Expiry alerts"
|
||||
subtitle={`Rewards lapsing in the next ${HORIZON_DAYS} days`}
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={2} height={64} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="expiry"
|
||||
title="Nothing expiring"
|
||||
description="No active rewards lapse in the next two weeks."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rewards) => {
|
||||
const soon = rewards
|
||||
.map((r) => ({r, days: daysUntil(r.expiresAt, nowMs)}))
|
||||
.filter(
|
||||
(x): x is {r: Reward; days: number} =>
|
||||
x.days !== null && x.days >= 0 && x.days <= HORIZON_DAYS,
|
||||
)
|
||||
.sort((a, b) => a.days - b.days);
|
||||
|
||||
if (soon.length === 0) {
|
||||
return (
|
||||
<EmptyPanel
|
||||
icon="expiry"
|
||||
title="Nothing expiring"
|
||||
description="No active rewards lapse in the next two weeks."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
{soon.map(({r, days}) => {
|
||||
const outstanding = r.claimed - r.used;
|
||||
return (
|
||||
<Banner
|
||||
key={r.id}
|
||||
// Under 3 days is an emergency; the rest is a heads-up.
|
||||
status={days <= 3 ? 'error' : 'warning'}
|
||||
title={`${r.name} expires ${days === 0 ? 'today' : days === 1 ? 'tomorrow' : `in ${days} days`}`}
|
||||
description={`${formatCompact(outstanding)} unredeemed claims · ${formatLyt(outstanding * r.costLyt)} outstanding`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
91
src/features/lyts/components/RewardCard.tsx
Normal file
91
src/features/lyts/components/RewardCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatLyt, formatPct} from '@/shared/utils/format';
|
||||
import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus';
|
||||
import {daysUntil, usageRate} from '@/features/lyts/services/lytsService';
|
||||
import type {Reward} from '@/features/lyts/types/reward';
|
||||
|
||||
/**
|
||||
* One reward.
|
||||
*
|
||||
* The progress bar is claimed-vs-redeemed, and it is the point of the card:
|
||||
* the unfilled portion is outstanding liability — LYTs a customer holds and
|
||||
* can spend at any time. A reward claimed 800 times and redeemed 150 is not a
|
||||
* popular offer, it is ₹78,000 of unbudgeted exposure.
|
||||
*/
|
||||
export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
|
||||
const status = REWARD_STATUS[reward.status];
|
||||
const rate = usageRate(reward.claimed, reward.used);
|
||||
const days = daysUntil(reward.expiresAt, nowMs);
|
||||
const outstanding = reward.claimed - reward.used;
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={reward.name}
|
||||
subtitle={
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Icon icon={ICONS.lyt} size="sm" color="secondary" />
|
||||
<Text size="base" weight="medium">
|
||||
{formatLyt(reward.costLyt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={<Badge variant={status.variant} label={status.label} />}
|
||||
/>
|
||||
|
||||
<VStack gap={2}>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
max={100}
|
||||
label={`Redeemed ${formatPct(rate, 0)} of claims`}
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<HStack hAlign="between">
|
||||
<Text size="sm" weight="medium">
|
||||
{formatCompact(reward.used)} redeemed
|
||||
</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
{formatCompact(reward.claimed)} claimed
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Outstanding"
|
||||
value={formatLyt(outstanding * reward.costLyt)}
|
||||
/>
|
||||
<StatPair
|
||||
label={days === null ? 'Expires' : days < 0 ? 'Expired' : 'Expires in'}
|
||||
value={
|
||||
days === null
|
||||
? 'Never'
|
||||
: days < 0
|
||||
? `${Math.abs(days)}d ago`
|
||||
: `${days}d`
|
||||
}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
105
src/features/lyts/components/RewardGrid.tsx
Normal file
105
src/features/lyts/components/RewardGrid.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/shared/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {RewardCard} from './RewardCard';
|
||||
import type {Reward, RewardStatus} from '@/features/lyts/types/reward';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
type Filter = 'all' | RewardStatus;
|
||||
|
||||
const REWARD_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function RewardGrid({
|
||||
resource,
|
||||
nowMs,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
nowMs: number;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Memoised on resource.data: `?? []` allocates a fresh array every render,
|
||||
// which would make it an unstable dependency and recompute both memos below
|
||||
// on every keystroke.
|
||||
const rewards = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
// Counts come from the unfiltered set so the chips still read "Expiring (2)"
|
||||
// while another filter is active.
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: rewards.length,
|
||||
active: 0,
|
||||
paused: 0,
|
||||
expiring: 0,
|
||||
expired: 0,
|
||||
};
|
||||
for (const r of rewards) c[r.status] += 1;
|
||||
return c;
|
||||
}, [rewards]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rewards.filter(
|
||||
(r) =>
|
||||
(filter === 'all' || r.status === filter) &&
|
||||
(q === '' || r.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [rewards, query, filter]);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="Reward catalogue">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search rewards…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
filterLabel="Filter rewards by status"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'active', label: 'Active', hint: counts.active},
|
||||
{value: 'expiring', label: 'Expiring', hint: counts.expiring},
|
||||
{value: 'paused', label: 'Paused', hint: counts.paused},
|
||||
{value: 'expired', label: 'Expired', hint: counts.expired},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'reward' : 'rewards'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={230} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards yet"
|
||||
description="Create a reward to let customers spend their LYTs in store."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards match"
|
||||
description="Try a different status filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={REWARD_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((r) => (
|
||||
<RewardCard key={r.id} reward={r} nowMs={nowMs} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
143
src/features/lyts/components/RewardPerformanceTable.tsx
Normal file
143
src/features/lyts/components/RewardPerformanceTable.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {formatCompact, formatLyt} from '@/shared/utils/format';
|
||||
import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus';
|
||||
import {usageRate} from '@/features/lyts/services/lytsService';
|
||||
import type {Reward} from '@/features/lyts/types/reward';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
status: Reward['status'];
|
||||
costLyt: number;
|
||||
claimed: number;
|
||||
used: number;
|
||||
rate: number;
|
||||
outstanding: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole catalogue as rows.
|
||||
*
|
||||
* A table rather than more cards: this is the view for comparing eight
|
||||
* rewards on the same four numbers, and cards force the eye to travel in two
|
||||
* dimensions to do a one-dimensional comparison. Sorted by redemption rate so
|
||||
* the offers that aren't working surface at the bottom without hunting.
|
||||
*/
|
||||
export function RewardPerformanceTable({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
}) {
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Reward',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="base" weight="semibold">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={REWARD_STATUS[row.status].variant}
|
||||
label={REWARD_STATUS[row.status].label}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'costLyt',
|
||||
header: 'Cost',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="base" weight="medium">{formatLyt(row.costLyt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'claimed',
|
||||
header: 'Claimed',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.claimed)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'used',
|
||||
header: 'Redeemed',
|
||||
width: pixel(112),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.used)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
header: 'Rate',
|
||||
width: pixel(112),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="end">
|
||||
<MetricDelta value={row.rate - 50} isRiseGood size="sm" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outstanding',
|
||||
header: 'Outstanding',
|
||||
width: pixel(140),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="base" weight="semibold">{formatLyt(row.outstanding)}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Reward performance"
|
||||
subtitle="Sorted by redemption rate — the weakest offers sit at the bottom"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={32} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards to compare"
|
||||
description="Performance appears once rewards have been claimed."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rewards) => {
|
||||
const rows: Row[] = rewards
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
costLyt: r.costLyt,
|
||||
claimed: r.claimed,
|
||||
used: r.used,
|
||||
rate: usageRate(r.claimed, r.used),
|
||||
outstanding: (r.claimed - r.used) * r.costLyt,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
32
src/features/lyts/hooks/useLyts.ts
Normal file
32
src/features/lyts/hooks/useLyts.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {
|
||||
buildLytActivity,
|
||||
buildRedemptions,
|
||||
buildRewards,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* LYT data access — SAMPLE.
|
||||
*
|
||||
* The platform has no rewards, redemption or programme-activity resource, so
|
||||
* these read shared/mocks/sampleData.ts locally. When the endpoints exist,
|
||||
* swap each body for useResource(lytsRepository.x(useScope())).
|
||||
*/
|
||||
|
||||
export function useRewards() {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource((now) => buildRewards(storeId, range, now), [storeId, range]);
|
||||
}
|
||||
|
||||
export function useRedemptions() {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource((now) => buildRedemptions(storeId, range, now), [storeId, range]);
|
||||
}
|
||||
|
||||
export function useLytsActivity() {
|
||||
const {storeId} = useScope();
|
||||
return useSampleResource((now) => buildLytActivity(storeId, now), [storeId]);
|
||||
}
|
||||
53
src/features/lyts/services/lytsService.ts
Normal file
53
src/features/lyts/services/lytsService.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {Reward} from '@/features/lyts/types/reward';
|
||||
|
||||
/**
|
||||
* Domain rules for LYT rewards.
|
||||
*
|
||||
* The presentation mapping (status → badge tone) stays in utils/rewardStatus —
|
||||
* it knows about badges. These are the calculations behind it, which know only
|
||||
* about rewards, and which the expiry panel, the performance table and any
|
||||
* future export all have to agree on.
|
||||
*/
|
||||
|
||||
/** Whole days until expiry; negative once lapsed, null if it never expires. */
|
||||
export function daysUntil(
|
||||
expiresAt: string | null,
|
||||
nowMs: number,
|
||||
): number | null {
|
||||
if (!expiresAt) return null;
|
||||
return Math.ceil((new Date(expiresAt).getTime() - nowMs) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claimed → used conversion, as a percentage.
|
||||
*
|
||||
* Zero claims is 0%, not NaN: a reward nobody has taken up has a usage rate,
|
||||
* and it is zero. Rounded to one decimal because the figure is read, not
|
||||
* computed against.
|
||||
*/
|
||||
export function usageRate(claimed: number, used: number): number {
|
||||
return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewards that need a decision, soonest deadline first.
|
||||
*
|
||||
* `withinDays` is the merchant's attention window rather than a fixed
|
||||
* constant — the expiry panel asks for 7, a daily digest would ask for 1.
|
||||
* Already-expired rewards are excluded: they are history, and mixing them into
|
||||
* an alert list is how a panel about acting becomes a panel about reading.
|
||||
*/
|
||||
export function selectExpiring(
|
||||
rewards: Reward[],
|
||||
nowMs: number,
|
||||
withinDays: number,
|
||||
): Reward[] {
|
||||
return rewards
|
||||
.map((reward) => ({reward, days: daysUntil(reward.expiresAt, nowMs)}))
|
||||
.filter(
|
||||
(entry): entry is {reward: Reward; days: number} =>
|
||||
entry.days !== null && entry.days >= 0 && entry.days <= withinDays,
|
||||
)
|
||||
.sort((a, b) => a.days - b.days)
|
||||
.map((entry) => entry.reward);
|
||||
}
|
||||
25
src/features/lyts/utils/rewardStatus.ts
Normal file
25
src/features/lyts/utils/rewardStatus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {RewardStatus} from '@/features/lyts/types/reward';
|
||||
|
||||
/**
|
||||
* Reward status → badge treatment.
|
||||
*
|
||||
* Only `expiring` and `expired` carry colour. An active reward is the normal
|
||||
* case and a paused one is a deliberate choice by the merchant — neither
|
||||
* needs attention. Colouring all four would leave nothing standing out on a
|
||||
* page whose entire job is surfacing the ones about to lapse.
|
||||
*/
|
||||
export const REWARD_STATUS: Record<
|
||||
RewardStatus,
|
||||
{label: string; variant: 'neutral' | 'warning' | 'error'}
|
||||
> = {
|
||||
active: {label: 'Active', variant: 'neutral'},
|
||||
paused: {label: 'Paused', variant: 'neutral'},
|
||||
expiring: {label: 'Expiring', variant: 'warning'},
|
||||
expired: {label: 'Expired', variant: 'error'},
|
||||
};
|
||||
|
||||
/*
|
||||
* daysUntil / usageRate used to live here. They are calculations about
|
||||
* rewards, not about badges, so they moved to services/lytsService — this
|
||||
* module is the presentation mapping and nothing else.
|
||||
*/
|
||||
@@ -11,6 +11,13 @@ 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';
|
||||
import {
|
||||
SAMPLE_API_KEYS,
|
||||
SAMPLE_WEBHOOKS,
|
||||
SAMPLE_WEBHOOK_LOGS,
|
||||
sampleApiKeyPrefix,
|
||||
sampleWebhookSecret,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
|
||||
export interface ApiKeyItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
@@ -38,73 +45,10 @@ export interface WebhookLogItem extends Record<string, unknown> {
|
||||
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 [keys, setKeys] = useState<ApiKeyItem[]>(SAMPLE_API_KEYS);
|
||||
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(SAMPLE_WEBHOOKS);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [newWebhookUrl, setNewWebhookUrl] = useState('');
|
||||
@@ -117,7 +61,7 @@ export function ApiWebhooksManager() {
|
||||
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)}`,
|
||||
keyPrefix: sampleApiKeyPrefix(),
|
||||
created: 'Just now',
|
||||
lastUsed: 'Never',
|
||||
status: 'active',
|
||||
@@ -142,7 +86,7 @@ export function ApiWebhooksManager() {
|
||||
id: `wh-${Date.now()}`,
|
||||
url: newWebhookUrl,
|
||||
events: 'order.created, lyt.redeemed, staff.checkin',
|
||||
secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`,
|
||||
secret: sampleWebhookSecret(),
|
||||
status: 'healthy',
|
||||
};
|
||||
setWebhooks((prev) => [newWh, ...prev]);
|
||||
@@ -374,7 +318,7 @@ export function ApiWebhooksManager() {
|
||||
>
|
||||
<ResponsiveTable
|
||||
columns={logColumns}
|
||||
data={WEBHOOK_LOGS}
|
||||
data={SAMPLE_WEBHOOK_LOGS}
|
||||
primaryKey="event"
|
||||
/>
|
||||
</StaticPanel>
|
||||
|
||||
@@ -26,36 +26,13 @@ export interface InvoiceItem extends Record<string, unknown> {
|
||||
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';
|
||||
import {SAMPLE_BANK_ACCOUNT, SAMPLE_INVOICES} from '@/shared/mocks/sampleData';
|
||||
|
||||
export function BillingOverview() {
|
||||
const toast = useToast();
|
||||
const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842');
|
||||
const [ifsc, setIfsc] = useState('HDFC0001234');
|
||||
const [bankAccount, setBankAccount] = useState(SAMPLE_BANK_ACCOUNT.account);
|
||||
const [ifsc, setIfsc] = useState(SAMPLE_BANK_ACCOUNT.ifsc);
|
||||
const [isUpdatingBank, setIsUpdatingBank] = useState(false);
|
||||
|
||||
const handleUpdateBank = () => {
|
||||
@@ -220,7 +197,7 @@ export function BillingOverview() {
|
||||
<StaticPanel title="Invoice Statements & Payment History" subtitle="Download tax invoices and monthly receipts.">
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={INVOICES}
|
||||
data={SAMPLE_INVOICES}
|
||||
primaryKey="id"
|
||||
/>
|
||||
</StaticPanel>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 {SAMPLE_BUSINESS} from '@/shared/mocks/sampleData';
|
||||
|
||||
export interface BusinessData {
|
||||
businessName: string;
|
||||
@@ -26,25 +27,11 @@ export interface BusinessData {
|
||||
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 [form, setForm] = useState<BusinessData>(SAMPLE_BUSINESS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
|
||||
const [savedData, setSavedData] = useState<BusinessData>(SAMPLE_BUSINESS);
|
||||
|
||||
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
|
||||
setForm((f) => ({...f, [key]: val}));
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
import {SAMPLE_INTEGRATIONS} from '@/shared/mocks/sampleData';
|
||||
|
||||
export interface IntegrationApp {
|
||||
id: string;
|
||||
@@ -21,69 +22,9 @@ export interface IntegrationApp {
|
||||
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 [apps, setApps] = useState<IntegrationApp[]>(SAMPLE_INTEGRATIONS);
|
||||
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {securityRepository} from '@/features/settings/repositories/securityRepository';
|
||||
import type {DeviceSession} from '@/features/settings/services/mapSession';
|
||||
import {SAMPLE_LOGIN_AUDIT} from '@/shared/mocks/sampleData';
|
||||
|
||||
export interface ActiveSessionItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
@@ -36,33 +37,6 @@ export interface LoginAuditItem extends Record<string, unknown> {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
export function SecurityManager() {
|
||||
const toast = useToast();
|
||||
const [twoFactor, setTwoFactor] = useState(true);
|
||||
@@ -311,7 +285,7 @@ export function SecurityManager() {
|
||||
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
|
||||
<ResponsiveTable
|
||||
columns={auditColumns}
|
||||
data={AUDIT_LOGS}
|
||||
data={SAMPLE_LOGIN_AUDIT}
|
||||
primaryKey="user"
|
||||
/>
|
||||
</StaticPanel>
|
||||
|
||||
65
src/features/staff/components/AttendanceChart.tsx
Normal file
65
src/features/staff/components/AttendanceChart.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {formatCompact, formatDayLabel} from '@/shared/utils/format';
|
||||
import type {AttendancePoint} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Daily attendance, stacked.
|
||||
*
|
||||
* PRESENT IS GRAY. Colouring it green produced a wall of saturated bars that
|
||||
* dominated the page and buried the only thing worth looking at — the amber
|
||||
* and red slivers. Present is the baseline, not an event; the exceptions are
|
||||
* what a merchant scans for, and they are what gets colour.
|
||||
*
|
||||
* This keeps the app's rule intact (colour marks what needs action) while
|
||||
* still letting late and absent read instantly without a legend lookup.
|
||||
*
|
||||
* Stacked rather than grouped because the meaningful quantity is the roster
|
||||
* total and how it splits — grouped bars would make you add three numbers to
|
||||
* answer "how many were rostered on Friday".
|
||||
*/
|
||||
export function AttendanceChart({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<AttendancePoint[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Attendance"
|
||||
subtitle="Daily roster split — present, late, absent"
|
||||
resource={resource}
|
||||
>
|
||||
{(d) => (
|
||||
<BarChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
isStacked
|
||||
// Encoding is forced solid: the colour already separates these three,
|
||||
// so the default hatch on the third series would be noise on top of
|
||||
// an already-unambiguous signal.
|
||||
series={[
|
||||
{key: 'present', label: 'Present', encoding: 'solid'},
|
||||
{
|
||||
key: 'late',
|
||||
label: 'Late',
|
||||
color: CHART.attention,
|
||||
encoding: 'solid',
|
||||
},
|
||||
{
|
||||
key: 'absent',
|
||||
label: 'Absent',
|
||||
color: CHART.negative,
|
||||
encoding: 'solid',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
183
src/features/staff/components/Leaderboard.tsx
Normal file
183
src/features/staff/components/Leaderboard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {formatCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
role: string;
|
||||
storeId: string;
|
||||
attendance: Attendance;
|
||||
salesCount: number;
|
||||
rewardsIssued: number;
|
||||
performanceScore: number;
|
||||
punctualityPct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole team, ranked.
|
||||
*
|
||||
* A table because this is a one-dimensional comparison across many rows —
|
||||
* cards would force the eye to travel in two dimensions to answer "who is
|
||||
* third". Capped at the top 10: a leaderboard that lists everyone is a
|
||||
* roster, and the roster already exists above.
|
||||
*/
|
||||
export function Leaderboard({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<StaffMember[]>;
|
||||
}) {
|
||||
// Resolved from the workspace store directory, not from a fixture — see
|
||||
// useStoreDirectory.
|
||||
const storeName = useStoreName();
|
||||
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'rank',
|
||||
header: '#',
|
||||
width: pixel(48),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.rank}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Team 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.role} · {storeName(row.storeId)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: 'Today',
|
||||
width: pixel(112),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={ATTENDANCE[row.attendance].badge}
|
||||
label={ATTENDANCE[row.attendance].label}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'salesCount',
|
||||
header: 'Sales',
|
||||
width: pixel(88),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatCompact(row.salesCount)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rewardsIssued',
|
||||
header: 'Rewards',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatCompact(row.rewardsIssued)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'punctualityPct',
|
||||
header: 'Punctuality',
|
||||
width: pixel(112),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatPct(row.punctualityPct, 0)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'performanceScore',
|
||||
header: 'Score',
|
||||
// 140px was too narrow for a bar plus a 2-digit number side by side —
|
||||
// the value wrapped under the bar and read as two separate figures.
|
||||
width: pixel(180),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center" wrap="nowrap">
|
||||
<ProgressBar
|
||||
value={row.performanceScore}
|
||||
max={100}
|
||||
label={`Score ${row.performanceScore} of 100`}
|
||||
isLabelHidden
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.performanceScore}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Leaderboard"
|
||||
subtitle="Top 10 by performance score across the selected scope"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={40} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="leaderboard"
|
||||
title="No one to rank yet"
|
||||
description="The leaderboard fills in once staff record sales."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(staff) => {
|
||||
const rows: Row[] = [...staff]
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore)
|
||||
.slice(0, 10)
|
||||
.map((m, i) => ({
|
||||
id: m.id,
|
||||
rank: i + 1,
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
storeId: m.storeId,
|
||||
attendance: m.attendance,
|
||||
salesCount: m.salesCount,
|
||||
rewardsIssued: m.rewardsIssued,
|
||||
performanceScore: m.performanceScore,
|
||||
punctualityPct: m.punctualityPct,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
87
src/features/staff/components/StaffCard.tsx
Normal file
87
src/features/staff/components/StaffCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {formatCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {StaffMember} from '@/features/staff/types/staff';
|
||||
|
||||
/**
|
||||
* One team member.
|
||||
*
|
||||
* The performance score gets a bar rather than a bare number because a score
|
||||
* out of 100 is meaningless without a sense of the scale — 68 reads very
|
||||
* differently as a filled bar than as a digit. Sales and rewards stay as
|
||||
* plain figures; they are counts, not proportions.
|
||||
*/
|
||||
export function StaffCard({member}: {member: StaffMember}) {
|
||||
const attendance = ATTENDANCE[member.attendance];
|
||||
// Resolved from the workspace store directory, not from a fixture — see
|
||||
// useStoreDirectory.
|
||||
const storeName = useStoreName();
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={member.name}
|
||||
subtitle={
|
||||
<Text size="sm" color="secondary">
|
||||
{member.role} · {storeName(member.storeId)}
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Badge variant={attendance.badge} label={attendance.label} />
|
||||
}
|
||||
/>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
<Avatar name={member.name} size="md" tooltip={false} />
|
||||
<VStack gap={1} width="100%">
|
||||
<ProgressBar
|
||||
value={member.performanceScore}
|
||||
max={100}
|
||||
label={`Performance score ${member.performanceScore} of 100`}
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<HStack hAlign="between">
|
||||
<Text size="xsm" color="secondary">
|
||||
Performance
|
||||
</Text>
|
||||
<Text size="xsm" weight="medium">
|
||||
{member.performanceScore}/100
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair label="Sales" value={formatCompact(member.salesCount)} />
|
||||
<StatPair
|
||||
label="Rewards issued"
|
||||
value={formatCompact(member.rewardsIssued)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Punctuality"
|
||||
value={formatPct(member.punctualityPct, 0)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
90
src/features/staff/components/StaffGrid.tsx
Normal file
90
src/features/staff/components/StaffGrid.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/shared/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {StaffCard} from './StaffCard';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {
|
||||
countByAttendance,
|
||||
selectStaff,
|
||||
} from '@/features/staff/services/staffService';
|
||||
import type {StaffFilter} from '@/features/staff/services/staffService';
|
||||
import type {StaffMember} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<StaffFilter>('all');
|
||||
|
||||
// `?? []` allocates a fresh array every render, which would make it an
|
||||
// unstable dependency and recompute both memos on every keystroke.
|
||||
const staff = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
// Both derivations live in staffService: what counts as a match and what
|
||||
// order the roster is in are product rules, and a redesign of this grid must
|
||||
// not be able to change them by accident. The memos stay here because
|
||||
// memoisation is a React concern, not a domain one.
|
||||
const counts = useMemo(() => countByAttendance(staff), [staff]);
|
||||
|
||||
const visible = useMemo(
|
||||
() => selectStaff(staff, {filter, search: query}),
|
||||
[staff, query, filter],
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="Team roster">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search name or role…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as StaffFilter)}
|
||||
filterLabel="Filter staff by attendance"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'present', label: ATTENDANCE.present.label, hint: counts.present},
|
||||
{value: 'late', label: ATTENDANCE.late.label, hint: counts.late},
|
||||
{value: 'absent', label: ATTENDANCE.absent.label, hint: counts.absent},
|
||||
{value: 'leave', label: ATTENDANCE.leave.label, hint: counts.leave},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'person' : 'people'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={230} minWidth={300} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No staff yet"
|
||||
description="Team members appear here once they are added to a store."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="Nobody matches"
|
||||
description="Try a different attendance filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={STAFF_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((m) => (
|
||||
<StaffCard key={m.id} member={m} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
107
src/features/staff/components/StaffKpis.tsx
Normal file
107
src/features/staff/components/StaffKpis.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {formatPct} from '@/shared/utils/format';
|
||||
import type {StaffSummary} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
* Attendance counts.
|
||||
*
|
||||
* Deliberately NOT MetricCard: these have no trend line and no
|
||||
* period-over-period delta — "present today" has nothing meaningful to
|
||||
* compare against last month. What they carry instead is a status dot and a
|
||||
* share-of-total, so they get their own small card rather than a MetricCard
|
||||
* with two empty slots.
|
||||
*/
|
||||
function CountCard({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
tone: StatusDotVariant;
|
||||
}) {
|
||||
const share = total === 0 ? 0 : (value / total) * 100;
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
{/* Padding and gaps match MetricCard so this row is the same height as
|
||||
the dashboard KPI row (~112px). */}
|
||||
<Card padding={4}>
|
||||
<VStack gap={1}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<StatusDot variant={tone} label={label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* Text, not Heading — a count is data, not a section title. */}
|
||||
<Text type="display-3">
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
format={(v) => String(Math.round(v))}
|
||||
/>
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatPct(share, 0)} of {total} rostered
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
|
||||
export function StaffKpis({resource}: {resource: Resource<StaffSummary>}) {
|
||||
const columns = useMetricColumns();
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(s) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<CountCard
|
||||
label={ATTENDANCE.present.label}
|
||||
value={s.present}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.present.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label={ATTENDANCE.late.label}
|
||||
value={s.late}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.late.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label={ATTENDANCE.absent.label}
|
||||
value={s.absent}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.absent.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label="Total staff"
|
||||
value={s.total}
|
||||
total={s.total}
|
||||
tone="neutral"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
32
src/features/staff/hooks/useStaff.ts
Normal file
32
src/features/staff/hooks/useStaff.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {
|
||||
buildAttendance,
|
||||
buildStaff,
|
||||
buildStaffSummary,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* Shop-floor staff data — SAMPLE.
|
||||
*
|
||||
* The platform's /api/team is who can sign in, not who worked a shift, so
|
||||
* attendance, sales-per-head and the leaderboard read
|
||||
* shared/mocks/sampleData.ts locally. The Team table (features/team) is real.
|
||||
*/
|
||||
|
||||
export function useStaffList() {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource(() => buildStaff(storeId, range), [storeId, range]);
|
||||
}
|
||||
|
||||
export function useStaffSummary() {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource(() => buildStaffSummary(storeId, range), [storeId, range]);
|
||||
}
|
||||
|
||||
export function useStaffAttendance() {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource((now) => buildAttendance(storeId, range, now), [storeId, range]);
|
||||
}
|
||||
60
src/features/staff/services/staffService.ts
Normal file
60
src/features/staff/services/staffService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
|
||||
|
||||
/**
|
||||
* Domain rules for the staff module.
|
||||
*
|
||||
* These were inline in StaffGrid's useMemo. They are rules, not rendering:
|
||||
* "the roster is ordered by who is carrying the store" is a product decision
|
||||
* that a redesign of the grid must not be able to change by accident, and a
|
||||
* second surface (the leaderboard, an export, a mobile view) has to be able to
|
||||
* reuse it without copying a filter chain out of a component.
|
||||
*
|
||||
* Framework-free on purpose — no React, no Astryx — so it is unit-testable and
|
||||
* survives any change to how staff are displayed.
|
||||
*/
|
||||
|
||||
export type StaffFilter = Attendance | 'all';
|
||||
|
||||
export interface StaffQuery {
|
||||
filter: StaffFilter;
|
||||
/** Free text, matched against name and role. */
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter, then rank.
|
||||
*
|
||||
* Best performers first: the roster's default question is "who is carrying
|
||||
* this store", not "who was added first". Ordering by score rather than name
|
||||
* is the single decision that makes the grid answer it without a sort control.
|
||||
*/
|
||||
export function selectStaff(
|
||||
staff: StaffMember[],
|
||||
{filter, search}: StaffQuery,
|
||||
): StaffMember[] {
|
||||
const q = search.trim().toLowerCase();
|
||||
return staff
|
||||
.filter(
|
||||
(m) =>
|
||||
(filter === 'all' || m.attendance === filter) &&
|
||||
(q === '' ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q)),
|
||||
)
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
}
|
||||
|
||||
/** How many people are in each attendance state. Drives the filter counts. */
|
||||
export function countByAttendance(
|
||||
staff: StaffMember[],
|
||||
): Record<StaffFilter, number> {
|
||||
const counts: Record<StaffFilter, number> = {
|
||||
all: staff.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
};
|
||||
for (const member of staff) counts[member.attendance] += 1;
|
||||
return counts;
|
||||
}
|
||||
82
src/features/stores/components/StoreCard.tsx
Normal file
82
src/features/stores/components/StoreCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {Store, StoreStatus} from '@/features/stores/types/store';
|
||||
|
||||
/**
|
||||
* Status is the one thing on this card allowed colour, because it is the one
|
||||
* thing that can require action. A closed or under-maintenance store is why a
|
||||
* merchant opened this page; its visitor count is context.
|
||||
*/
|
||||
const STATUS: Record<
|
||||
StoreStatus,
|
||||
{label: string; variant: 'success' | 'warning' | 'error' | 'neutral'}
|
||||
> = {
|
||||
open: {label: 'Open', variant: 'success'},
|
||||
maintenance: {label: 'Maintenance', variant: 'warning'},
|
||||
closed: {label: 'Closed', variant: 'error'},
|
||||
};
|
||||
|
||||
export function StoreCard({store}: {store: Store}) {
|
||||
const status = STATUS[store.status];
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={store.name}
|
||||
subtitle={
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Icon icon={ICONS.staff} size="xsm" color="secondary" />
|
||||
<Text size="sm" color="secondary">
|
||||
{store.staffCount} staff
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={<Badge variant={status.variant} label={status.label} />}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair label="Visitors" value={formatCompact(store.visitors)} />
|
||||
<StatPair
|
||||
label="Purchases"
|
||||
value={formatCompact(store.purchases)}
|
||||
/>
|
||||
</StatRow>
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Revenue"
|
||||
value={formatInrCompact(store.revenueInr)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Conversion"
|
||||
value={formatPct(store.conversionPct)}
|
||||
/>
|
||||
</StatRow>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Open dashboard"
|
||||
href={`/stores/${store.id}`}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
108
src/features/stores/components/StoreGrid.tsx
Normal file
108
src/features/stores/components/StoreGrid.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/shared/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {StoreCard} from './StoreCard';
|
||||
import type {Store, StoreStatus} from '@/features/stores/types/store';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
type Filter = 'all' | StoreStatus;
|
||||
|
||||
/**
|
||||
* Three-up on desktop, per spec.
|
||||
*
|
||||
* minWidth 270 is what actually yields three tracks once the 16px gaps are
|
||||
* subtracted from a ~880px workspace — 300 silently collapsed it to two.
|
||||
* Capped at 3 so a wide monitor doesn't stretch the roster into a thin strip,
|
||||
* and it steps down to two when the Loyaly AI panel is taking 380px.
|
||||
*/
|
||||
const STORE_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function StoreGrid({resource}: {resource: Resource<Store[]>}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Memoised on resource.data: `?? []` allocates a fresh array every render,
|
||||
// which would make it an unstable dependency and recompute both memos below
|
||||
// on every keystroke.
|
||||
const stores = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
// Counts come from the unfiltered set so the chips still read "Closed (1)"
|
||||
// while the closed filter is active — otherwise every chip but the selected
|
||||
// one shows zero, which is useless.
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: stores.length,
|
||||
open: 0,
|
||||
maintenance: 0,
|
||||
closed: 0,
|
||||
};
|
||||
for (const s of stores) c[s.status] += 1;
|
||||
return c;
|
||||
}, [stores]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return stores.filter(
|
||||
(s) =>
|
||||
(filter === 'all' || s.status === filter) &&
|
||||
(q === '' || s.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [stores, query, filter]);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="All stores">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search stores…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
filterLabel="Filter stores by status"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'open', label: 'Open', hint: counts.open},
|
||||
{value: 'maintenance', label: 'Maintenance', hint: counts.maintenance},
|
||||
{value: 'closed', label: 'Closed', hint: counts.closed},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'store' : 'stores'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={240} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No stores yet"
|
||||
description="Stores appear here once they are added to your Loyaly account."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
// A filter returning nothing is a different situation from having
|
||||
// no stores at all, and needs a different way out.
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No stores match"
|
||||
description="Try a different status filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={STORE_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((s) => (
|
||||
<StoreCard key={s.id} store={s} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {storeRepository} from '@/features/stores/repositories/storeRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {buildStore, buildStores} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* Store data access. See features/dashboard/hooks/useDashboard for the pattern.
|
||||
* Store performance — SAMPLE.
|
||||
*
|
||||
* The platform's real estate (shops, cameras) comes from useSites(); it
|
||||
* reports no per-store revenue, staffing or trend, so the performance grid
|
||||
* and the store detail page read shared/mocks/sampleData.ts locally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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'}));
|
||||
return useSampleResource((now) => buildStores(range, now), [range]);
|
||||
}
|
||||
|
||||
/** Null data when the id is not a sample store — the page renders not-found. */
|
||||
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,
|
||||
});
|
||||
return useSampleResource((now) => buildStore(storeId, range, now), [storeId, range]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -14,7 +14,28 @@ import type {BucketSize} from './types';
|
||||
* changes this reads from the merchant profile rather than moving. */
|
||||
export const DEFAULT_TZ = 'Asia/Kolkata';
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const SHOP_DAY = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: DEFAULT_TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
/**
|
||||
* Today's date IN THE SHOP'S ZONE — "2026-09-27".
|
||||
*
|
||||
* Never `toISOString().slice(0, 10)`: that is the UTC date, which for India is
|
||||
* still yesterday until 05:30, so "Today" would have shown yesterday's trade
|
||||
* for the first five and a half hours of every business day.
|
||||
*/
|
||||
export function shopToday(now: Date): string {
|
||||
return SHOP_DAY.format(now);
|
||||
}
|
||||
|
||||
/** Calendar arithmetic on a plain date — no clock, no zone, no DST. */
|
||||
export function shiftDay(day: string, days: number): string {
|
||||
const d = new Date(`${day}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -25,46 +46,60 @@ export interface ReportWindow {
|
||||
bucket: BucketSize;
|
||||
}
|
||||
|
||||
/** A custom window, both ends inclusive, already validated by parseQuery. */
|
||||
export interface CustomWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `to` is INCLUSIVE upstream — "the 1st to the 7th" includes the 7th — so the
|
||||
* window ends today rather than tomorrow, and a 7-day range spans today plus
|
||||
* the six days before it.
|
||||
* the six days before it. Every date is the shop's calendar date.
|
||||
*/
|
||||
export function toReportWindow(
|
||||
range: RangeKey,
|
||||
now: Date = new Date(),
|
||||
bucket?: BucketSize,
|
||||
custom?: CustomWindow | null,
|
||||
): ReportWindow {
|
||||
const to = new Date(now);
|
||||
const from = new Date(now);
|
||||
const today = shopToday(now);
|
||||
let from = today;
|
||||
let to = today;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
break;
|
||||
case '7d':
|
||||
from.setUTCDate(to.getUTCDate() - 6);
|
||||
from = shiftDay(today, -6);
|
||||
break;
|
||||
case '30d':
|
||||
from.setUTCDate(to.getUTCDate() - 29);
|
||||
from = shiftDay(today, -29);
|
||||
break;
|
||||
case '90d':
|
||||
from.setUTCDate(to.getUTCDate() - 89);
|
||||
from = shiftDay(today, -89);
|
||||
break;
|
||||
case 'mtd':
|
||||
from.setUTCDate(1);
|
||||
from = `${today.slice(0, 8)}01`;
|
||||
break;
|
||||
case 'ytd':
|
||||
from.setUTCMonth(0, 1);
|
||||
from = `${today.slice(0, 5)}01-01`;
|
||||
break;
|
||||
case 'custom':
|
||||
// The picker's real dates are not threaded through the scope yet, so a
|
||||
// custom window falls back to 30 days rather than silently returning the
|
||||
// whole estate's history.
|
||||
from.setUTCDate(to.getUTCDate() - 29);
|
||||
// No valid dates (parseQuery rejected them) → the default 30 days,
|
||||
// never the estate's whole history.
|
||||
if (custom) {
|
||||
from = custom.from;
|
||||
to = custom.to;
|
||||
} else {
|
||||
from = shiftDay(today, -29);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
from: ymd(from),
|
||||
to: ymd(to),
|
||||
from,
|
||||
to,
|
||||
tz: DEFAULT_TZ,
|
||||
bucket: bucket ?? defaultBucket(range),
|
||||
};
|
||||
@@ -72,6 +107,7 @@ export function toReportWindow(
|
||||
|
||||
/** A 90-day window bucketed by hour is 2,160 points in a 300px chart. */
|
||||
export function defaultBucket(range: RangeKey): BucketSize {
|
||||
if (range === 'today') return 'hour';
|
||||
if (range === 'ytd') return 'month';
|
||||
if (range === '90d') return 'week';
|
||||
return 'day';
|
||||
|
||||
148
src/shared/components/charts/CalendarHeatmap.tsx
Normal file
148
src/shared/components/charts/CalendarHeatmap.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import {HStack, StackItem, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
||||
import {ACCENT, type BrandAccent} from '@/shared/utils/accent';
|
||||
import {formatBucketLabel, formatCompact} from '@/shared/utils/format';
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const DAY_MS = 86_400_000;
|
||||
/**
|
||||
* Weeks drawn, however short the window. The grid is right-aligned and
|
||||
* clipped, so a narrow card shows the most recent weeks and a wide one shows
|
||||
* more history — without measuring anything in JS.
|
||||
*/
|
||||
const MIN_WEEKS = 26;
|
||||
|
||||
/** Days since the epoch for a `YYYY-MM-DD…` label, or null. */
|
||||
function dayNumber(label: string): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(label);
|
||||
return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / DAY_MS : null;
|
||||
}
|
||||
|
||||
function isoDay(n: number): string {
|
||||
return new Date(n * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// 1970-01-01 was a Thursday, so Monday = 0 is (n + 3) mod 7.
|
||||
const weekday = (n: number) => (((n + 3) % 7) + 7) % 7;
|
||||
|
||||
interface Cell {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** One day's cell — fixed size, so the grid never stretches to fill. */
|
||||
function Square({className}: {className: string}) {
|
||||
return <StackItem className={`size-4 shrink-0 rounded-sm ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily values as a contribution calendar: weekdays down, weeks across,
|
||||
* newest week on the right.
|
||||
*
|
||||
* Every day is a square. Days inside the report window carry a tooltip;
|
||||
* zero and out-of-window days share the empty tint, as they do on GitHub.
|
||||
* Labels are only ever parsed as UTC calendar dates (no clock), which cannot
|
||||
* shift a day in the viewer's timezone.
|
||||
*
|
||||
* Intensity is quantised to the accent's five-step ramp, lightest first.
|
||||
*/
|
||||
export function CalendarHeatmap({
|
||||
data,
|
||||
accent,
|
||||
unit,
|
||||
height,
|
||||
}: {
|
||||
data: Cell[];
|
||||
accent: BrandAccent;
|
||||
/** Plural noun for the tooltip: "203 visitors". */
|
||||
unit: string;
|
||||
height?: number;
|
||||
}) {
|
||||
const ramp = ACCENT[accent].ramp;
|
||||
const byDay = new Map<number, number>();
|
||||
for (const c of data) {
|
||||
const n = dayNumber(c.label);
|
||||
if (n !== null) byDay.set(n, c.value);
|
||||
}
|
||||
const days = [...byDay.keys()];
|
||||
if (days.length === 0) return null;
|
||||
|
||||
const first = Math.min(...days);
|
||||
const last = Math.max(...days);
|
||||
const lastWeek = last - weekday(last);
|
||||
const weeks = Math.max(MIN_WEEKS, Math.floor((lastWeek - (first - weekday(first))) / 7) + 1);
|
||||
const start = lastWeek - (weeks - 1) * 7;
|
||||
const max = Math.max(...byDay.values()) || 1;
|
||||
|
||||
const level = (v: number) =>
|
||||
v <= 0 ? -1 : Math.min(ramp.length - 1, Math.ceil((v / max) * ramp.length) - 1);
|
||||
const tint = (step: number) => (step < 0 ? 'bg-gray-subtle' : ramp[step]);
|
||||
const monthOf = (n: number) => new Date(n * DAY_MS).getUTCMonth();
|
||||
|
||||
return (
|
||||
<VStack gap={3} height={height} vAlign="center">
|
||||
<HStack gap={2}>
|
||||
{/* Weekday gutter: a header-height spacer, then one slot per row. */}
|
||||
<VStack gap={1}>
|
||||
<StackItem className="h-4" />
|
||||
{DAYS.map((d, i) => (
|
||||
<HStack key={d} vAlign="center" className="h-4">
|
||||
<Text size="xsm" color="secondary">
|
||||
{i % 2 === 0 && i < 6 ? d : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{/* Right-aligned and clipped: overflow falls off the OLD end. */}
|
||||
<HStack gap={1} hAlign="end" className="min-w-0 flex-1 overflow-hidden">
|
||||
{Array.from({length: weeks}, (_, w) => {
|
||||
const weekStart = start + w * 7;
|
||||
const newMonth = w === 0 || monthOf(weekStart) !== monthOf(weekStart - 7);
|
||||
return (
|
||||
<VStack key={w} gap={1} className="shrink-0">
|
||||
<HStack vAlign="center" className="h-4 w-4 overflow-visible whitespace-nowrap">
|
||||
<Text size="xsm" color="secondary">
|
||||
{newMonth && w > 0 ? MONTHS[monthOf(weekStart)] : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
{DAYS.map((dayLabel, d) => {
|
||||
const n = weekStart + d;
|
||||
if (n < first || n > last) {
|
||||
return <Square key={d} className={n > last ? '' : tint(-1)} />;
|
||||
}
|
||||
const value = byDay.get(n) ?? 0;
|
||||
return (
|
||||
<Tooltip
|
||||
key={d}
|
||||
hasHoverIndication={false}
|
||||
content={`${dayLabel} ${formatBucketLabel(isoDay(n))} — ${formatCompact(value)} ${unit}`}
|
||||
>
|
||||
<Square className={tint(level(value))} />
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} vAlign="center" hAlign="end">
|
||||
<Text size="xsm" color="secondary">
|
||||
Less
|
||||
</Text>
|
||||
{[-1, ...ramp.keys()].map((step) => (
|
||||
<Square key={step} className={tint(step)} />
|
||||
))}
|
||||
<Text size="xsm" color="secondary">
|
||||
More
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -30,9 +30,12 @@ export function MetricCard({
|
||||
trend,
|
||||
footer,
|
||||
accent,
|
||||
unavailableNote,
|
||||
caption,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Null when the metric is not reported — shown as a dash, never as 0. */
|
||||
value: number | null;
|
||||
format: (v: number) => string;
|
||||
icon?: IconType;
|
||||
deltaPct?: number;
|
||||
@@ -42,6 +45,10 @@ export function MetricCard({
|
||||
/** Replaces the sparkline — e.g. a share-of-total caption. */
|
||||
footer?: React.ReactNode;
|
||||
accent?: 'warm' | 'cool';
|
||||
/** Why `value` is null, in a few words — "Cost not recorded". */
|
||||
unavailableNote?: string;
|
||||
/** A small supporting line under the value — "Today ₹8,450". */
|
||||
caption?: React.ReactNode;
|
||||
}) {
|
||||
const isWarm =
|
||||
accent === 'warm' ||
|
||||
@@ -75,14 +82,31 @@ export function MetricCard({
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="end" hAlign="between" className="min-w-0">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
className="truncate shrink-0 tracking-tight leading-none"
|
||||
>
|
||||
<AnimatedNumber value={value} format={format} />
|
||||
</Text>
|
||||
{footer ?? (
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
className="truncate shrink-0 tracking-tight leading-none"
|
||||
>
|
||||
{value === null ? (
|
||||
<Text type="inherit" color="disabled">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<AnimatedNumber value={value} format={format} />
|
||||
)}
|
||||
</Text>
|
||||
{caption ? (
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{caption}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
{value === null && unavailableNote ? (
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{unavailableNote}
|
||||
</Text>
|
||||
) : footer ?? (
|
||||
<HStack className="h-9 w-1/2 max-w-40 shrink">
|
||||
{trend && trend.length > 1 ? (
|
||||
<Sparkline data={trend} dataKey="v" height={36} accent={tone} />
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {useInSampleScope} from '@/shared/mocks/SampleScope';
|
||||
|
||||
/**
|
||||
* Title + optional subtitle + optional trailing actions.
|
||||
@@ -32,6 +34,8 @@ export function SectionHeader({
|
||||
actions?: React.ReactNode;
|
||||
level?: 2 | 3 | 4 | 5;
|
||||
}) {
|
||||
// Inside a <SampleScope>, every panel says so — see shared/mocks.
|
||||
const isSample = useInSampleScope();
|
||||
return (
|
||||
<HStack hAlign="between" vAlign="start" gap={3}>
|
||||
<VStack gap={0.5}>
|
||||
@@ -44,8 +48,12 @@ export function SectionHeader({
|
||||
subtitle
|
||||
)}
|
||||
</VStack>
|
||||
{actions ? (
|
||||
{actions || isSample ? (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<SampleTag
|
||||
show={isSample}
|
||||
description="The platform does not report this yet — showing an example"
|
||||
/>
|
||||
{actions}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
93
src/shared/components/scope/DateTabs.tsx
Normal file
93
src/shared/components/scope/DateTabs.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {SegmentedControl, SegmentedControlItem} from '@astryxdesign/core/SegmentedControl';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Dialog} from '@astryxdesign/core/Dialog';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import type {RangeKey} from '@/shared/providers/WorkspaceProvider';
|
||||
import {CalendarRangePicker} from './CalendarRangePicker';
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** "Sep 01" from "2026-09-01" — by string, so no zone can move the day. */
|
||||
function short(day: string): string {
|
||||
const [, m, d] = day.split('-');
|
||||
return `${MONTHS[Number(m) - 1]} ${d}`;
|
||||
}
|
||||
|
||||
const TABS: {value: RangeKey; label: string}[] = [
|
||||
{value: 'today', label: 'Today'},
|
||||
{value: '7d', label: '7 Days'},
|
||||
{value: '30d', label: '30 Days'},
|
||||
];
|
||||
|
||||
/**
|
||||
* The period as visible tabs — one click per period, no dropdown.
|
||||
*
|
||||
* [ Today ] [ 7 Days ] [ 30 Days ] [ Custom ]
|
||||
*
|
||||
* Writes the same workspace range every other page reads, so it drives the
|
||||
* same `/api/*?range=` requests. Choosing Custom opens the calendar; the range
|
||||
* only switches to custom once dates are applied, so cancelling leaves the
|
||||
* previous period in place. A SegmentedControl does not re-fire for the
|
||||
* segment already selected, so an active custom range gets its own small
|
||||
* "change dates" button.
|
||||
*
|
||||
* A range picked on another page that has no tab here (90 days, month to
|
||||
* date…) simply shows no tab selected until one is chosen.
|
||||
*/
|
||||
export function DateTabs() {
|
||||
const {range, setRange, customRange, setCustomRange} = useWorkspace();
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
|
||||
const customLabel =
|
||||
range === 'custom' && customRange
|
||||
? `Custom · ${short(customRange.start)} – ${short(customRange.end)}`
|
||||
: 'Custom';
|
||||
|
||||
return (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<SegmentedControl
|
||||
label="Period"
|
||||
size="sm"
|
||||
value={range}
|
||||
onChange={(v) => {
|
||||
if (v === 'custom') setIsPicking(true);
|
||||
else setRange(v as RangeKey);
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<SegmentedControlItem key={t.value} value={t.value} label={t.label} />
|
||||
))}
|
||||
<SegmentedControlItem value="custom" label={customLabel} />
|
||||
</SegmentedControl>
|
||||
|
||||
{range === 'custom' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Change dates"
|
||||
isIconOnly
|
||||
icon={<Icon icon="calendar" size="sm" />}
|
||||
onClick={() => setIsPicking(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Dialog isOpen={isPicking} onOpenChange={setIsPicking} width={640} purpose="info">
|
||||
<CalendarRangePicker
|
||||
initialStart={customRange?.start ?? ''}
|
||||
initialEnd={customRange?.end ?? ''}
|
||||
onApply={(start, end) => {
|
||||
setCustomRange({start, end});
|
||||
setRange('custom');
|
||||
setIsPicking(false);
|
||||
}}
|
||||
onCancel={() => setIsPicking(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,17 @@ import type {Scope} from '@/shared/services/httpClient';
|
||||
* this reference, but a stable object keeps it out of other dependency arrays.
|
||||
*/
|
||||
export function useScope(): Scope {
|
||||
const {storeId, range} = useWorkspace();
|
||||
return useMemo(() => ({storeId, range}), [storeId, range]);
|
||||
const {storeId, range, customRange} = useWorkspace();
|
||||
const from = customRange?.start;
|
||||
const to = customRange?.end;
|
||||
return useMemo(
|
||||
() => ({
|
||||
storeId,
|
||||
range,
|
||||
// Picked dates travel only with a custom range; they are the calendar
|
||||
// days the merchant chose, as plain strings, so no zone can shift them.
|
||||
custom: range === 'custom' && from && to ? {from, to} : null,
|
||||
}),
|
||||
[storeId, range, from, to],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,10 @@ export interface NavEntry {
|
||||
*/
|
||||
export const PRIMARY_NAV: NavEntry[] = [
|
||||
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
||||
{label: 'Customers', href: '/customers', icon: ICONS.profile},
|
||||
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
||||
{label: 'Store', href: '/stores', icon: ICONS.stores},
|
||||
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
||||
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
||||
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
|
||||
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
||||
];
|
||||
|
||||
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */
|
||||
|
||||
12
src/shared/mocks/SampleNotice.tsx
Normal file
12
src/shared/mocks/SampleNotice.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
|
||||
/**
|
||||
* TEMPORARY — the page-level half of a <SampleScope>: one sentence at the
|
||||
* top of a screen whose figures are examples, naming what the platform does
|
||||
* not report yet. The per-panel "Sample data" tokens carry the rest.
|
||||
*/
|
||||
export function SampleNotice({children}: {children: string}) {
|
||||
return <Banner status="info" title="Showing sample data" description={children} />;
|
||||
}
|
||||
25
src/shared/mocks/SampleScope.tsx
Normal file
25
src/shared/mocks/SampleScope.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import {createContext, useContext} from 'react';
|
||||
|
||||
/**
|
||||
* TEMPORARY — marks a subtree whose panels are ALWAYS sample data.
|
||||
*
|
||||
* The screens ported from the merchant app (LYT rewards, staff attendance,
|
||||
* commerce analytics, store intelligence) read shared/mocks/sampleData.ts:
|
||||
* the platform has no resource behind any of them. Rather than
|
||||
* threading `actions={<SampleTag show />}` through thirty components, a page
|
||||
* wraps them once and every SectionHeader inside wears the token.
|
||||
*
|
||||
* Removing sample data: delete the wrapped panels, then this file, and drop
|
||||
* the `useInSampleScope()` line from SectionHeader.
|
||||
*/
|
||||
const SampleContext = createContext(false);
|
||||
|
||||
export function SampleScope({children}: {children: React.ReactNode}) {
|
||||
return <SampleContext.Provider value>{children}</SampleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useInSampleScope(): boolean {
|
||||
return useContext(SampleContext);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import {Token} from '@astryxdesign/core/Token';
|
||||
* TEMPORARY — marks a panel that is showing sample data rather than the
|
||||
* merchant's own, so an example can never be read as a real number.
|
||||
*/
|
||||
export function SampleTag({show}: {show: boolean}) {
|
||||
export function SampleTag({
|
||||
show,
|
||||
description = 'No data for this period yet — showing an example',
|
||||
}: {
|
||||
show: boolean;
|
||||
description?: string;
|
||||
}) {
|
||||
return show ? (
|
||||
<Token
|
||||
size="sm"
|
||||
label="Sample data"
|
||||
description="No data for this period yet — showing an example"
|
||||
/>
|
||||
<Token size="sm" label="Sample data" description={description} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
1952
src/shared/mocks/sampleData.ts
Normal file
1952
src/shared/mocks/sampleData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,25 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TEMPORARY — the sample-data fallback shared by every mock folder.
|
||||
* TEMPORARY — the two ways a page reads sample data.
|
||||
*
|
||||
* Real data always wins. `withSample()` substitutes the sample only when the
|
||||
* platform answered successfully with NO signal (empty, or all zeros). Loading
|
||||
* and error pass through untouched, so a failing API is never papered over.
|
||||
* withSample() real endpoint first; the sample stands in only when the
|
||||
* platform answered with NO signal (a brand-new shop).
|
||||
* useSampleResource() no endpoint exists at all; the sample IS the data, and
|
||||
* the panel sits inside a <SampleScope>.
|
||||
*
|
||||
* Every sample value comes from shared/mocks/sampleData.ts — the single file
|
||||
* to edit, and eventually delete.
|
||||
*
|
||||
* ── Removing sample data ─────────────────────────────────────────────────
|
||||
* Delete this folder and every `features/<feature>/mocks/` folder, then fix
|
||||
* the call sites TypeScript points at: `withSample(x, …)` → `x`, and drop each
|
||||
* `<SampleTag …/>`.
|
||||
* Delete this folder, then fix the call sites TypeScript points at:
|
||||
* `withSample(x, …)` → `x`, `useSampleResource(…)` → the real hook, and drop
|
||||
* each <SampleTag/>, <SampleScope> and <SampleNotice>.
|
||||
*/
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {ReportBucket} from '@/features/dashboard/types/reports';
|
||||
|
||||
export type SampleAware<T> = Resource<T> & {isSample: boolean};
|
||||
|
||||
@@ -26,3 +34,56 @@ export function withSample<T>(
|
||||
}
|
||||
return {...resource, isSample: false};
|
||||
}
|
||||
|
||||
/** True when the report carries at least one non-zero value for `key`. */
|
||||
export function hasSignal(
|
||||
report: {buckets: ReportBucket[]} | undefined,
|
||||
key: keyof Omit<ReportBucket, 'label'>,
|
||||
): boolean {
|
||||
return !!report && report.buckets.some((b) => b[key] > 0);
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* "Now" for sample builders: the start of today (UTC).
|
||||
*
|
||||
* Day-granular on purpose. The server render and the hydration pass happen
|
||||
* milliseconds apart, and a millisecond clock would seed different numbers
|
||||
* into each — a hydration mismatch. A day boundary is only crossed at
|
||||
* midnight.
|
||||
*/
|
||||
export function sampleNowMs(): number {
|
||||
return Math.floor(Date.now() / DAY_MS) * DAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled Resource built locally from sample data — the same shape
|
||||
* useResource() returns, so a component cannot tell the difference and
|
||||
* swapping in the real endpoint later is a one-line change in the hook.
|
||||
*
|
||||
* An empty array settles as 'empty', like a real response would.
|
||||
*/
|
||||
export function useSampleResource<T>(
|
||||
build: (nowMs: number) => T,
|
||||
deps: readonly unknown[],
|
||||
): Resource<T> {
|
||||
// The scope values `build` reads, as one stable key — so the sample is
|
||||
// rebuilt when the store or period changes, and not on every render.
|
||||
const key = JSON.stringify(deps);
|
||||
return useMemo<Resource<T>>(() => {
|
||||
const nowMs = sampleNowMs();
|
||||
const data = build(nowMs);
|
||||
const isEmpty = Array.isArray(data) && data.length === 0;
|
||||
return {
|
||||
status: isEmpty ? 'empty' : 'success',
|
||||
data,
|
||||
error: undefined,
|
||||
refetch: () => {},
|
||||
isRefreshing: false,
|
||||
meta: {generatedAt: new Date(nowMs).toISOString()},
|
||||
} as Resource<T>;
|
||||
// `build` is a fresh closure every render; `key` names what it reads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ import {useSites} from '@/features/stores/hooks/useSites';
|
||||
* a page that ignores scope simply shows no control for it.
|
||||
*/
|
||||
|
||||
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
|
||||
export type RangeKey = 'today' | '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
|
||||
|
||||
export const RANGE_LABELS: Record<RangeKey, string> = {
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
|
||||
@@ -2,11 +2,36 @@ import type {NextRequest} from 'next/server';
|
||||
import {getServerSession} from '@/features/auth/services/serverSession';
|
||||
import type {ApiErrorCode, ApiFailure, ApiSuccess, RangeKey} from '@/shared/types/api';
|
||||
|
||||
const RANGES: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
|
||||
const RANGES: RangeKey[] = ['today', '7d', '30d', '90d', 'mtd', 'ytd', 'custom'];
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
/** A year and a day — enough for "last year", small enough to page. */
|
||||
const MAX_CUSTOM_DAYS = 366;
|
||||
|
||||
/**
|
||||
* `from`/`to` for a custom range: real calendar dates, in order, within a
|
||||
* year. Anything else is null, and the route treats the range as 30 days —
|
||||
* a malformed query never widens to the estate's whole history.
|
||||
*/
|
||||
function parseCustom(p: URLSearchParams): {from: string; to: string} | null {
|
||||
const from = p.get('from');
|
||||
const to = p.get('to');
|
||||
if (!from || !to || !DAY.test(from) || !DAY.test(to)) return null;
|
||||
const a = Date.parse(`${from}T00:00:00Z`);
|
||||
const b = Date.parse(`${to}T00:00:00Z`);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a > b) return null;
|
||||
// Rejects 2026-02-31, which Date.parse silently rolls into March.
|
||||
if (new Date(a).toISOString().slice(0, 10) !== from) return null;
|
||||
if (new Date(b).toISOString().slice(0, 10) !== to) return null;
|
||||
if ((b - a) / 86_400_000 > MAX_CUSTOM_DAYS) return null;
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
range: RangeKey;
|
||||
storeId: string;
|
||||
/** Set only when `range` is 'custom' and the dates were valid. */
|
||||
custom: {from: string; to: string} | null;
|
||||
/** Dev-only forced state. */
|
||||
state?: 'error' | 'empty' | 'loading';
|
||||
delayMs: number;
|
||||
@@ -46,9 +71,13 @@ export function parseQuery(req: NextRequest): Query {
|
||||
const delay = Number(p.get('_delay') ?? 0);
|
||||
const state = p.get('_state');
|
||||
|
||||
const parsed = RANGES.includes(range as RangeKey) ? (range as RangeKey) : '30d';
|
||||
const custom = parsed === 'custom' ? parseCustom(p) : null;
|
||||
|
||||
return {
|
||||
range: RANGES.includes(range as RangeKey) ? (range as RangeKey) : '30d',
|
||||
range: parsed,
|
||||
storeId: p.get('storeId') || 'all',
|
||||
custom,
|
||||
state:
|
||||
state === 'error' || state === 'empty' || state === 'loading'
|
||||
? state
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Endpoint<T> {
|
||||
export interface Scope {
|
||||
range: RangeKey;
|
||||
storeId: string;
|
||||
/** The picked dates, both inclusive — sent only when `range` is 'custom'. */
|
||||
custom?: {from: string; to: string} | null;
|
||||
}
|
||||
|
||||
/** Build a scope-filtered endpoint. Feature repositories only. */
|
||||
@@ -48,7 +50,14 @@ export function scopedEndpoint<T>(
|
||||
scope: Scope,
|
||||
extra: Record<string, string> = {},
|
||||
): Endpoint<T> {
|
||||
return {path, params: {range: scope.range, storeId: scope.storeId, ...extra}};
|
||||
const custom: Record<string, string> =
|
||||
scope.range === 'custom' && scope.custom
|
||||
? {from: scope.custom.from, to: scope.custom.to}
|
||||
: {};
|
||||
return {
|
||||
path,
|
||||
params: {range: scope.range, storeId: scope.storeId, ...custom, ...extra},
|
||||
};
|
||||
}
|
||||
|
||||
export function endpointUrl(e: Endpoint<unknown>): string {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* already the contract, not a description of one.
|
||||
*/
|
||||
|
||||
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
|
||||
export type RangeKey = 'today' | '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
|
||||
|
||||
export interface ApiMeta {
|
||||
/** The SERVER's clock. Every relative time in the UI measures against it. */
|
||||
|
||||
@@ -14,24 +14,29 @@
|
||||
*
|
||||
* `ink` is the readable slot (icons, text); `soft` is a tint for an icon chip
|
||||
* only. Nothing here paints a card background: the cards stay white.
|
||||
*
|
||||
* `ramp` is five intensity steps of the base hue, lightest first, for a
|
||||
* heatmap's cells. Written out in full so Tailwind can see every class.
|
||||
*/
|
||||
|
||||
export type BrandAccent = 'warm' | 'cool';
|
||||
|
||||
export const ACCENT: Record<
|
||||
BrandAccent,
|
||||
{ink: string; soft: string; solid: string; chip: string}
|
||||
{ink: string; soft: string; solid: string; chip: string; ramp: readonly string[]}
|
||||
> = {
|
||||
warm: {
|
||||
ink: 'text-brand-warm-ink',
|
||||
soft: 'bg-brand-warm-soft',
|
||||
solid: 'bg-brand-warm',
|
||||
chip: 'bg-brand-warm-soft text-brand-warm-ink',
|
||||
ramp: ['bg-brand-warm/20', 'bg-brand-warm/40', 'bg-brand-warm/60', 'bg-brand-warm/80', 'bg-brand-warm'],
|
||||
},
|
||||
cool: {
|
||||
ink: 'text-brand-cool-ink',
|
||||
soft: 'bg-brand-cool-soft',
|
||||
solid: 'bg-brand-cool',
|
||||
chip: 'bg-brand-cool-soft text-brand-cool-ink',
|
||||
ramp: ['bg-brand-cool/20', 'bg-brand-cool/40', 'bg-brand-cool/60', 'bg-brand-cool/80', 'bg-brand-cool'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,6 +95,7 @@ export const ICONS = {
|
||||
dashboard: LayoutDashboard,
|
||||
commerce: ShoppingCart,
|
||||
stores: Store,
|
||||
cameras: Camera,
|
||||
lyts: Gift,
|
||||
staff: Users,
|
||||
settings: Settings,
|
||||
|
||||
Reference in New Issue
Block a user