update ui changes

This commit is contained in:
2026-09-28 16:35:34 +05:30
parent 88f658fba9
commit f6d81ceaa8
87 changed files with 7157 additions and 1237 deletions

View File

@@ -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>
);
}

View File

@@ -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');
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

@@ -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,
);
}

View File

@@ -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},
];

View 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,
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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]);
}

View File

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

View 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;
}

View 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;
}

View File

@@ -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. */

View File

@@ -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. */

View File

@@ -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';
/**

View File

@@ -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',
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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'});

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View 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);
}

View 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.
*/

View File

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

View File

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

View File

@@ -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}));

View File

@@ -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('');

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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]);
}

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View 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>
);
}

View File

@@ -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],
);
}

View File

@@ -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. */

View 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} />;
}

View 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);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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]);
}

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

@@ -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'],
},
};

View File

@@ -95,6 +95,7 @@ export const ICONS = {
dashboard: LayoutDashboard,
commerce: ShoppingCart,
stores: Store,
cameras: Camera,
lyts: Gift,
staff: Users,
settings: Settings,