diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx
index 0e5b169..3093196 100644
--- a/src/app/(workspace)/commerce/page.tsx
+++ b/src/app/(workspace)/commerce/page.tsx
@@ -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 = (
+
+);
+
+// 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(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 (
- }
- />
+
+ }
+ />
+
- }
- >
- {(report) => (
-
- )}
-
-
- }
- loading={}
- empty={
-
- }
- >
- {/*
- 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) => (
-
- {rows.map((sale) => (
- 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)}
- />
- ))}
-
- )}
-
-
- {openSale ? (
- setOpenSale(null)} />
+ {sales.isSample ? (
+
+ No sales recorded in this period yet. The revenue, orders and outlet
+ figures below are examples until staff record sales in the merchant
+ app.
+
) : 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.
- */}
-
- }
- >
-
- {MOCK_TOP_PRODUCTS.map((p, i) => (
-
- ))}
-
-
-
- }
- >
-
+
-
+
+ formatPct(v)}
+ icon={ICONS.analytics}
+ accent="cool"
+ unavailableNote="Not reported"
+ />
+
+ {/* SAMPLE — the sale list records no refund or discount. */}
+
+ ) : sales.status === 'error' ? null : (
+
+ )}
+
+
+ }
+ resource={sales}
+ loading={}
+ empty={noSales}
+ >
+ {() => }
+
+
+ }
+ resource={sales}
+ loading={}
+ empty={noSales}
+ >
+ {() => (
+
+ )}
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/(workspace)/customers/page.tsx b/src/app/(workspace)/customers/page.tsx
index 23a7246..b7c5cd4 100644
--- a/src/app/(workspace)/customers/page.tsx
+++ b/src/app/(workspace)/customers/page.tsx
@@ -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 (
-
-
-
-
- );
+ redirect('/dashboard#customers');
}
diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx
index 859a0ad..7fb0f89 100644
--- a/src/app/(workspace)/dashboard/page.tsx
+++ b/src/app/(workspace)/dashboard/page.tsx
@@ -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 .
+ const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
+ 'loyaly.dashboard.supporting-analytics',
+ false,
+ );
+ const [granularity, setGranularity] = useState('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={}
>
{(report) => (
- ({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},
- ]}
/>
)}
@@ -235,9 +263,116 @@ export default function DashboardPage() {
)}
+
+
+
+ {/* Text, not Heading: Collapsible renders the trigger inside a
+
+
+ {/* What to do next — last because it is the conclusion. SAMPLE. */}
+
+
+
+
-
+ {/*
+ 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.
+ */}
+
+
+
+
);
}
diff --git a/src/app/(workspace)/lyts/page.tsx b/src/app/(workspace)/lyts/page.tsx
index 01ceffa..ed75de2 100644
--- a/src/app/(workspace)/lyts/page.tsx
+++ b/src/app/(workspace)/lyts/page.tsx
@@ -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 (
-
-
+ const rewards = useRewards();
+ const redemptions = useRedemptions();
+ const activity = useLytsActivity();
-
-
+ 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 (
+
+
+ }
+ />
+
+
+ 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.
+
+
+
+
+
+
+ }
+ >
+ {(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 (
+
+ String(Math.round(v))}
+ icon={ICONS.activeRewards}
+ footer={
+ }
+ />
+ }
+ />
+
+ }
+ />
+
+ );
+ }}
+
+
+ {/*
+ The earning side, before the spending side. All ten activities, their
+ status and their LYT cost — the same model the dashboard summarises.
+ */}
+
+
+
+
+ {(d) => (
+
+ )}
+
+
+
+ {(d) => (
+
+ )}
+
+
+
+
+ {(rows) => (
+ b.claimed - a.claimed).slice(0, 6)}
+ xKey="name"
+ yFormat={formatCompact}
+ series={[
+ {key: 'claimed', label: 'Claimed'},
+ {key: 'used', label: 'Redeemed'},
+ ]}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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}
+
);
}
diff --git a/src/app/(workspace)/staff/page.tsx b/src/app/(workspace)/staff/page.tsx
index c6c9400..3379dfa 100644
--- a/src/app/(workspace)/staff/page.tsx
+++ b/src/app/(workspace)/staff/page.tsx
@@ -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 (
}
/>
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+ {(rows) => (
+ 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'},
+ ]}
+ />
+ )}
+
+
+
+
+
+ {/*
+ 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.
+ */}
+
+
+
-
-
);
}
diff --git a/src/app/(workspace)/stores/[storeId]/page.tsx b/src/app/(workspace)/stores/[storeId]/page.tsx
new file mode 100644
index 0000000..09890ef
--- /dev/null
+++ b/src/app/(workspace)/stores/[storeId]/page.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+
+
+ Store
+ {name}
+
+
+ }
+ actions={
+
+ {store.data ? (
+
+ ) : null}
+
+
+ }
+ />
+
+
+ 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.
+
+
+ }
+ >
+ {(rows) => (
+
+ {rows.map((k) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {(d) => (
+
+ )}
+
+
+
+ {(d) => (
+
+ )}
+
+
+
+ {(d) => (
+ formatPct(v, 0)}
+ series={[{key: 'conversion', label: 'Conversion'}]}
+ reference={{
+ y: 22,
+ label: 'Network benchmark',
+ tone: 'positive',
+ }}
+ />
+ )}
+
+
+
+ {(d) => }
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(workspace)/stores/cameras/page.tsx b/src/app/(workspace)/stores/cameras/page.tsx
new file mode 100644
index 0000000..d6e207a
--- /dev/null
+++ b/src/app/(workspace)/stores/cameras/page.tsx
@@ -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 (
+
+
+ Store
+ Live cameras
+
+
+
+
+ }
+ empty={
+
+ }
+ >
+ {(rows) => (
+
+ {rows.map((site) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx
index c14c3d6..0823e3b 100644
--- a/src/app/(workspace)/stores/page.tsx
+++ b/src/app/(workspace)/stores/page.tsx
@@ -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 (
-
- }
- empty={
- }
+ href="/stores/cameras"
/>
}
- >
- {(rows) => (
-
- {rows.map((site) => (
-
- ))}
-
- )}
-
+ />
+
+
+
+
+ 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.
+
+
+
+
);
}
diff --git a/src/app/api/activities/impact/route.ts b/src/app/api/activities/impact/route.ts
index 8cab623..81a7ee5 100644
--- a/src/app/api/activities/impact/route.ts
+++ b/src/app/api/activities/impact/route.ts
@@ -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) {
diff --git a/src/app/api/activities/route.ts b/src/app/api/activities/route.ts
index 242735e..c7123e4 100644
--- a/src/app/api/activities/route.ts
+++ b/src/app/api/activities/route.ts
@@ -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([
diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts
index f1f9d95..ad05137 100644
--- a/src/app/api/campaigns/route.ts
+++ b/src/app/api/campaigns/route.ts
@@ -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);
diff --git a/src/app/api/reports/conversion/route.ts b/src/app/api/reports/conversion/route.ts
index 1ad53b1..3adc0c7 100644
--- a/src/app/api/reports/conversion/route.ts
+++ b/src/app/api/reports/conversion/route.ts
@@ -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([
diff --git a/src/app/api/reports/footfall/route.ts b/src/app/api/reports/footfall/route.ts
index c8351b0..480ccdf 100644
--- a/src/app/api/reports/footfall/route.ts
+++ b/src/app/api/reports/footfall/route.ts
@@ -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
diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts
index a392df0..23499e7 100644
--- a/src/app/api/reports/journey/route.ts
+++ b/src/app/api/reports/journey/route.ts
@@ -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)),
diff --git a/src/app/api/sales/route.ts b/src/app/api/sales/route.ts
index 26bc7c3..f3b8ed5 100644
--- a/src/app/api/sales/route.ts
+++ b/src/app/api/sales/route.ts
@@ -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();
+ 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,
diff --git a/src/features/commerce/components/CommercePanels.tsx b/src/features/commerce/components/CommercePanels.tsx
new file mode 100644
index 0000000..4aeee6d
--- /dev/null
+++ b/src/features/commerce/components/CommercePanels.tsx
@@ -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 , 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 = (
+
+);
+
+export function PaymentMethodsPanel({sales}: {sales: Resource}) {
+ return (
+
+ }
+ empty={noSales}
+ >
+ {(rows) => {
+ const revenue = totalPaise(rows) / 100;
+ return (
+
+ {SAMPLE_PAYMENT_MIX.map((p) => (
+
+ `${v}% · ${formatInrCompact((revenue * p.pct) / 100)}`
+ }
+ />
+ ))}
+
+ );
+ }}
+
+
+ );
+}
+
+export function TopOutletsPanel({
+ sales,
+ isSample = false,
+}: {
+ sales: Resource;
+ 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 (
+ }
+ resource={sales}
+ loading={}
+ empty={noSales}
+ >
+ {(rows) => {
+ const outlets = byOutlet(rows);
+ const top = outlets[0]?.paise || 1;
+ return (
+
+ {outlets.map((o) => (
+
+ `${formatInrCompact(o.paise / 100)} · ${formatCompact(o.purchases)} orders`
+ }
+ />
+ ))}
+
+ );
+ }}
+
+ );
+}
+
+const STOCK: Record = {
+ in_stock: {label: 'In stock', variant: 'success'},
+ low_stock: {label: 'Low', variant: 'warning'},
+ out_of_stock: {label: 'Out', variant: 'error'},
+};
+
+const PRODUCT_COLUMNS: TableColumn[] = [
+ {
+ key: 'name',
+ header: 'Product',
+ width: proportional(2),
+ renderCell: (row) => (
+
+
+ {row.name}
+
+
+ {row.category}
+
+
+ ),
+ },
+ {
+ key: 'units',
+ header: 'Units',
+ width: pixel(80),
+ align: 'end',
+ renderCell: (row) => {formatCompact(row.units)},
+ },
+ {
+ key: 'revenueInr',
+ header: 'Revenue',
+ width: pixel(104),
+ align: 'end',
+ renderCell: (row) => (
+
+ {formatInrCompact(row.revenueInr)}
+
+ ),
+ },
+ {
+ key: 'deltaPct',
+ header: 'Change',
+ width: pixel(88),
+ align: 'end',
+ renderCell: (row) => ,
+ },
+ {
+ key: 'stockStatus',
+ header: 'Stock',
+ width: pixel(88),
+ align: 'end',
+ renderCell: (row) => (
+
+ ),
+ },
+];
+
+export function TopProductsPanel() {
+ const {storeId, range} = useScope();
+ const products = useSampleResource(() => buildTopProducts(storeId, range), [storeId, range]);
+
+ return (
+
+ }
+ >
+ {(rows) => (
+
+ )}
+
+
+ );
+}
+
+export function SalesInsightsPanel({sales}: {sales: Resource}) {
+ const {stores} = useWorkspace();
+ const name = (id: string) => stores.find((s) => s.id === id)?.name ?? id;
+
+ return (
+ }
+ 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: ,
+ },
+ 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: ,
+ },
+ lead && stores.length > 1 && {
+ id: 'outlet',
+ label: `${name(lead.siteId)} leads on sales`,
+ description: `${formatInr(lead.paise / 100)} from ${formatCompact(lead.purchases)} orders.`,
+ badge: ,
+ },
+ 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 (
+
+ {items.map((i) => (
+
+ ))}
+
+ );
+ }}
+
+ );
+}
+
+export function RevenueSummaryPanel({sales}: {sales: Resource}) {
+ return (
+
+ }
+ empty={noSales}
+ >
+ {(rows) => {
+ const gross = totalPaise(rows) / 100;
+ const discounts = gross * SAMPLE_DISCOUNT_RATE;
+ const refunds = gross * SAMPLE_REFUND_RATE;
+ return (
+
+
+
+
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/features/commerce/components/SalesTrendChart.tsx b/src/features/commerce/components/SalesTrendChart.tsx
new file mode 100644
index 0000000..4ff01fb
--- /dev/null
+++ b/src/features/commerce/components/SalesTrendChart.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ formatInrCompact(v)}
+ />
+ {
+ const p = active ? (payload?.[0]?.payload as TrendPoint | undefined) : undefined;
+ if (!p) return null;
+ return (
+
+
+
+ {p.label}
+
+
+ Sales {formatInr(p.sales)}
+
+ Purchases {formatCount(p.purchases)}
+
+
+ );
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/features/commerce/hooks/useSales.ts b/src/features/commerce/hooks/useSales.ts
index 9a502b9..a1de7da 100644
--- a/src/features/commerce/hooks/useSales.ts
+++ b/src/features/commerce/hooks/useSales.ts
@@ -9,3 +9,16 @@ import type {Sale} from '@/features/commerce/types/sale';
export function useSales(): Resource {
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 {
+ const {storeId} = useScope();
+ return useResource(
+ enabled ? saleRepository.list({storeId, range: 'today'}) : null,
+ );
+}
diff --git a/src/features/commerce/mocks/commerceMock.ts b/src/features/commerce/mocks/commerceMock.ts
deleted file mode 100644
index 23fac0d..0000000
--- a/src/features/commerce/mocks/commerceMock.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * TEMPORARY sample data for the Sales page.
- *
- * - `MOCK_SALES` stands in for the sales list only while the platform returns
- * none (see `shared/mocks/withSample.ts`). Sample rows never open the detail
- * dialog — there is no sale behind them to fetch.
- * - `MOCK_TOP_PRODUCTS` and `MOCK_PAYMENT_METHODS` have NO platform resource
- * yet. They are always sample and always tagged. When the catalogue and
- * payment endpoints ship, replace them with hooks and delete this file.
- *
- * Money is integer paise, like the real contract.
- */
-
-import type {Sale} from '@/features/commerce/types/sale';
-
-function sale(
- n: number,
- customer: string | null,
- staff: string,
- totalPaise: number,
- purchased: number,
- enquired: number,
- at: string,
-): Sale {
- return {
- id: `sample-sale-${n}`,
- invoiceNo: `INV-${String(2400 + n).padStart(5, '0')}`,
- siteId: 'sample',
- customerRef: null,
- customerLabel: customer,
- staffName: staff,
- totalPaise,
- currency: 'INR',
- status: 'completed',
- at,
- purchasedLines: purchased,
- enquiryLines: enquired,
- lines: [],
- };
-}
-
-export const MOCK_SALES: Sale[] = [
- sale(18, 'Priya S.', 'Arjun', 248_900, 3, 1, '2026-09-25T11:42:00Z'),
- sale(17, 'Rahul M.', 'Divya', 89_900, 1, 0, '2026-09-25T10:15:00Z'),
- sale(16, null, 'Arjun', 159_800, 2, 2, '2026-09-24T18:03:00Z'),
- sale(15, 'Kavya R.', 'Meena', 412_500, 4, 0, '2026-09-24T16:27:00Z'),
- sale(14, 'Sanjay K.', 'Divya', 64_900, 1, 1, '2026-09-24T13:50:00Z'),
- sale(13, 'Anitha P.', 'Meena', 198_000, 2, 0, '2026-09-23T19:11:00Z'),
-];
-
-export interface SampleProduct {
- id: string;
- name: string;
- sold: number;
- revenuePaise: number;
-}
-
-export const MOCK_TOP_PRODUCTS: SampleProduct[] = [
- {id: 'p1', name: 'Cotton kurta — indigo', sold: 42, revenuePaise: 5_451_600},
- {id: 'p2', name: 'Silk dupatta', sold: 31, revenuePaise: 3_689_000},
- {id: 'p3', name: 'Linen shirt — white', sold: 27, revenuePaise: 2_427_300},
- {id: 'p4', name: 'Handloom saree', sold: 12, revenuePaise: 5_388_000},
- {id: 'p5', name: 'Leather sandals', sold: 19, revenuePaise: 1_519_000},
-];
-
-export interface SamplePaymentMethod {
- method: string;
- /** Share of sales by count, as a percentage. */
- share: number;
-}
-
-export const MOCK_PAYMENT_METHODS: SamplePaymentMethod[] = [
- {method: 'UPI', share: 58},
- {method: 'Card', share: 24},
- {method: 'Cash', share: 14},
- {method: 'Wallet', share: 4},
-];
diff --git a/src/features/commerce/services/salesAnalysis.ts b/src/features/commerce/services/salesAnalysis.ts
new file mode 100644
index 0000000..20f38ad
--- /dev/null
+++ b/src/features/commerce/services/salesAnalysis.ts
@@ -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()},
+ ]),
+ );
+ const customers = new Set();
+ 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();
+ 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(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,
+ };
+}
diff --git a/src/features/dashboard/components/ActivityCard.tsx b/src/features/dashboard/components/ActivityCard.tsx
new file mode 100644
index 0000000..6fbc54e
--- /dev/null
+++ b/src/features/dashboard/components/ActivityCard.tsx
@@ -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 (
+
+
+
+
+ {/*
+ 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.
+ */}
+
+
+
+
+ {metric.label}
+
+
+ {isCatalogue ? (
+
+
+
+ {status.label}
+
+
+ ) : (
+
+ )}
+
+
+ {/* Data, not a section title — Text rather than Heading, so a grid of
+ activity counts stays out of the document outline. */}
+
+ {formatCompact(metric.count)}
+ {/* 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 ? (
+
+ ) : null}
+
+
+ {isCatalogue ? (
+
+ {metric.description}
+
+ ) : null}
+
+
+ {chain
+ .map((s) => `${formatCount(s.value)} ${s.label}`)
+ .join(' → ')}
+
+
+ {/* 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 ? (
+
+
+
+ {formatLyt(metric.lytsIssued)} issued
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/features/dashboard/components/ActivityGroups.tsx b/src/features/dashboard/components/ActivityGroups.tsx
new file mode 100644
index 0000000..9a5c96b
--- /dev/null
+++ b/src/features/dashboard/components/ActivityGroups.tsx
@@ -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;
+}) {
+ const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
+
+ return (
+
+ {ACTIVITY_GROUPS.map((group) => (
+ }
+ resource={resource}
+ loading={}
+ empty={
+
+ }
+ >
+ {(metrics) => (
+
+ {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.
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/features/dashboard/components/ActivityImpactTable.tsx b/src/features/dashboard/components/ActivityImpactTable.tsx
new file mode 100644
index 0000000..d473bf3
--- /dev/null
+++ b/src/features/dashboard/components/ActivityImpactTable.tsx
@@ -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 {
+ 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) => (
+
+ {formatCount(v)}
+
+);
+
+const COLUMNS: TableColumn[] = [
+ {
+ 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) => (
+
+
+
+ {row.label}
+
+
+ ),
+ },
+ {
+ 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) => (
+
+ {formatPct(row.conversion, 0)}
+
+ ),
+ },
+ {
+ 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) => (
+
+ {formatInrCompact(row.revenueInr)}
+
+ ),
+ },
+];
+
+export function ActivityImpactTable({
+ resource,
+}: {
+ resource: Resource;
+}) {
+ const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
+
+ return (
+ }
+ actions={}
+ empty={
+
+ }
+ >
+ {(metrics) => (
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/ActivityTimeline.tsx b/src/features/dashboard/components/ActivityTimeline.tsx
new file mode 100644
index 0000000..dae0ed1
--- /dev/null
+++ b/src/features/dashboard/components/ActivityTimeline.tsx
@@ -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;
+ 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 (
+ }
+ actions={
+ viewAllHref ? (
+
+ ) : undefined
+ }
+ empty={
+
+ }
+ >
+ {(events) =>
+ height ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+ );
+}
diff --git a/src/features/dashboard/components/AttributionNote.tsx b/src/features/dashboard/components/AttributionNote.tsx
new file mode 100644
index 0000000..2f7dccd
--- /dev/null
+++ b/src/features/dashboard/components/AttributionNote.tsx
@@ -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 (
+
+ {/*
+ 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.
+ */}
+
+
+
+
+ );
+}
diff --git a/src/features/dashboard/components/CustomerFlowChart.tsx b/src/features/dashboard/components/CustomerFlowChart.tsx
index db30392..2b24870 100644
--- a/src/features/dashboard/components/CustomerFlowChart.tsx
+++ b/src/features/dashboard/components/CustomerFlowChart.tsx
@@ -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 (
-
-
-
-
-
- {steps.map((s) => (
-
- ))}
-
-
-
-
+
+
+ {steps.map((s) => (
+
+
+ {s.name}
+
+
+ {formatCompact(s.value)}
+
+
+ {s.pct === null ? 'Unique people' : `${s.pct}% of visitors`}
+
+
+
+
+
+ ))}
+
+
);
}
diff --git a/src/features/dashboard/components/PerformancePanel.tsx b/src/features/dashboard/components/PerformancePanel.tsx
new file mode 100644
index 0000000..13ea6fe
--- /dev/null
+++ b/src/features/dashboard/components/PerformancePanel.tsx
@@ -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 (
+ onGranularityChange(v as Granularity)}
+ label="Performance granularity"
+ size="sm"
+ >
+
+
+
+ }
+ >
+ {(rows) => (
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/RewardUsageChart.tsx b/src/features/dashboard/components/RewardUsageChart.tsx
new file mode 100644
index 0000000..5bc06b5
--- /dev/null
+++ b/src/features/dashboard/components/RewardUsageChart.tsx
@@ -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;
+}) {
+ return (
+
+ {(rows) => (
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/StoreComparison.tsx b/src/features/dashboard/components/StoreComparison.tsx
new file mode 100644
index 0000000..34667d4
--- /dev/null
+++ b/src/features/dashboard/components/StoreComparison.tsx
@@ -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;
+}) {
+ return (
+
+ {(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 (
+
+ {rows.map((r) => (
+
+
+
+
+ {r.name}
+
+ {r.storeId === best.storeId ? (
+
+ ) : r.storeId === worst.storeId ? (
+
+ ) : null}
+
+
+ {/* A visitor count is data, not a section title. */}
+ {formatCompact(r.visitors)}
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }}
+
+ );
+}
diff --git a/src/features/dashboard/components/StoreInsights.tsx b/src/features/dashboard/components/StoreInsights.tsx
new file mode 100644
index 0000000..d29e615
--- /dev/null
+++ b/src/features/dashboard/components/StoreInsights.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {insight.title}
+
+
+ {insight.body}
+
+ {insight.action ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export function StoreInsights({resource}: {resource: Resource}) {
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(insights) => (
+
+ {insights.map((i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/hooks/useDashboard.ts b/src/features/dashboard/hooks/useDashboard.ts
index 3e9ec36..f426662 100644
--- a/src/features/dashboard/hooks/useDashboard.ts
+++ b/src/features/dashboard/hooks/useDashboard.ts
@@ -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;
}, [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 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;
+
+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]);
+}
diff --git a/src/features/dashboard/mocks/dashboardMock.ts b/src/features/dashboard/mocks/dashboardMock.ts
deleted file mode 100644
index 7f3d3eb..0000000
--- a/src/features/dashboard/mocks/dashboardMock.ts
+++ /dev/null
@@ -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,
-): boolean {
- return !!report && report.buckets.some((b) => b[key] > 0);
-}
-
-export {withSample} from '@/shared/mocks/withSample';
diff --git a/src/features/dashboard/services/activityService.ts b/src/features/dashboard/services/activityService.ts
new file mode 100644
index 0000000..01e62a6
--- /dev/null
+++ b/src/features/dashboard/services/activityService.ts
@@ -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 = {
+ 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;
+}
diff --git a/src/features/dashboard/types/intelligence.ts b/src/features/dashboard/types/intelligence.ts
new file mode 100644
index 0000000..59c484a
--- /dev/null
+++ b/src/features/dashboard/types/intelligence.ts
@@ -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;
+}
diff --git a/src/features/engagement/components/ActivitiesPanel.tsx b/src/features/engagement/components/ActivitiesPanel.tsx
index ca65b2d..852ab97 100644
--- a/src/features/engagement/components/ActivitiesPanel.tsx
+++ b/src/features/engagement/components/ActivitiesPanel.tsx
@@ -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. */
diff --git a/src/features/engagement/components/CampaignsPanel.tsx b/src/features/engagement/components/CampaignsPanel.tsx
index 46ab544..18e140a 100644
--- a/src/features/engagement/components/CampaignsPanel.tsx
+++ b/src/features/engagement/components/CampaignsPanel.tsx
@@ -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. */
diff --git a/src/features/engagement/components/JourneyPanel.tsx b/src/features/engagement/components/JourneyPanel.tsx
index 9e398c9..b80a6bc 100644
--- a/src/features/engagement/components/JourneyPanel.tsx
+++ b/src/features/engagement/components/JourneyPanel.tsx
@@ -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';
/**
diff --git a/src/features/engagement/mocks/engagementMock.ts b/src/features/engagement/mocks/engagementMock.ts
deleted file mode 100644
index ce976a2..0000000
--- a/src/features/engagement/mocks/engagementMock.ts
+++ /dev/null
@@ -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',
-};
diff --git a/src/features/loyaly-ai/components/Conversation.tsx b/src/features/loyaly-ai/components/Conversation.tsx
index 88c2e81..ec9fcba 100644
--- a/src/features/loyaly-ai/components/Conversation.tsx
+++ b/src/features/loyaly-ai/components/Conversation.tsx
@@ -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(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 (
-
+
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+ Ready to optimize your stores?
+
+
+ Your proactive AI copilot for smarter retail decisions and instant analytics.
+
+
+
+
+
+
+ Footfall{' '}
+
+ +{SAMPLE_AI_PULSE.footfallDeltaPct}%
+
+
+
+ Sync{' '}
+
+ {SAMPLE_AI_PULSE.syncPct}%
+
+
+
+
+
+
+
+
+ Active insights
+
+
+
+ {SAMPLE_AI_INSIGHTS.map((item) => (
+ onPick?.(item.prompt)}
+ padding={4}
+ >
+
+
+
+
+
+
+
+ {item.title}
+
+
+
+
+ {item.description}
+
+
+
+ {item.actionText}
+
+
+
+
+
+
+ ))}
+
+
+
+ Quick queries
+
+ {SUGGESTIONS.slice(0, 5).map((s) => (
+ onPick?.(s.prompt)} />
+ ))}
+
+
+
);
}
diff --git a/src/features/loyaly-ai/components/MessageBubble.tsx b/src/features/loyaly-ai/components/MessageBubble.tsx
index fb30639..fe95d5c 100644
--- a/src/features/loyaly-ai/components/MessageBubble.tsx
+++ b/src/features/loyaly-ai/components/MessageBubble.tsx
@@ -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 ? (
-
+
-
+
) : undefined
}
>
diff --git a/src/features/loyaly-ai/components/SuggestionChips.tsx b/src/features/loyaly-ai/components/SuggestionChips.tsx
deleted file mode 100644
index 2cf85ef..0000000
--- a/src/features/loyaly-ai/components/SuggestionChips.tsx
+++ /dev/null
@@ -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 (
-
- {SUGGESTIONS.map((s) => (
-
- ))}
-
- );
-}
diff --git a/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx b/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx
index 84a0e7c..a427127 100644
--- a/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx
+++ b/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx
@@ -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'});
diff --git a/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx b/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx
index 402ff53..9734eef 100644
--- a/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx
+++ b/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx
@@ -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 ? (
- Action:
+ Action:
{insight.recommendedAction}
diff --git a/src/features/loyaly-ai/components/response/KpiGridBlock.tsx b/src/features/loyaly-ai/components/response/KpiGridBlock.tsx
index d363be8..80fce78 100644
--- a/src/features/loyaly-ai/components/response/KpiGridBlock.tsx
+++ b/src/features/loyaly-ai/components/response/KpiGridBlock.tsx
@@ -105,7 +105,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
{kpi.trend}
diff --git a/src/features/lyts/components/ActivityProgramme.tsx b/src/features/lyts/components/ActivityProgramme.tsx
new file mode 100644
index 0000000..3e27153
--- /dev/null
+++ b/src/features/lyts/components/ActivityProgramme.tsx
@@ -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 (
+
+
+ {/*
+ 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' ? (
+
+ m.status === 'live').length} of ${metrics.data.length}`}
+ />
+ a + m.count, 0),
+ )}
+ />
+ Math.max(a, m.impact.customers),
+ 0,
+ ),
+ )}
+ />
+ a + m.lytsIssued, 0),
+ )}
+ align="end"
+ />
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/lyts/components/ExpiryAlerts.tsx b/src/features/lyts/components/ExpiryAlerts.tsx
new file mode 100644
index 0000000..12e5999
--- /dev/null
+++ b/src/features/lyts/components/ExpiryAlerts.tsx
@@ -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;
+ nowMs: number;
+}) {
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(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 (
+
+ );
+ }
+
+ return (
+
+ {soon.map(({r, days}) => {
+ const outstanding = r.claimed - r.used;
+ return (
+
+ );
+ })}
+
+ );
+ }}
+
+ );
+}
diff --git a/src/features/lyts/components/RewardCard.tsx b/src/features/lyts/components/RewardCard.tsx
new file mode 100644
index 0000000..8a3c687
--- /dev/null
+++ b/src/features/lyts/components/RewardCard.tsx
@@ -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 (
+
+
+
+
+
+
+ {formatLyt(reward.costLyt)}
+
+
+ }
+ actions={}
+ />
+
+
+
+
+
+ {formatCompact(reward.used)} redeemed
+
+
+ {formatCompact(reward.claimed)} claimed
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/lyts/components/RewardGrid.tsx b/src/features/lyts/components/RewardGrid.tsx
new file mode 100644
index 0000000..a51584e
--- /dev/null
+++ b/src/features/lyts/components/RewardGrid.tsx
@@ -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;
+ nowMs: number;
+}) {
+ const [query, setQuery] = useState('');
+ const [filter, setFilter] = useState('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 = {
+ 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 (
+
+ 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'}
+ />
+
+ }
+ empty={
+
+ }
+ >
+ {() =>
+ visible.length === 0 ? (
+
+ ) : (
+
+ {visible.map((r) => (
+
+ ))}
+
+ )
+ }
+
+
+ );
+}
diff --git a/src/features/lyts/components/RewardPerformanceTable.tsx b/src/features/lyts/components/RewardPerformanceTable.tsx
new file mode 100644
index 0000000..b785ff2
--- /dev/null
+++ b/src/features/lyts/components/RewardPerformanceTable.tsx
@@ -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 {
+ 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;
+}) {
+ const columns: TableColumn[] = [
+ {
+ key: 'name',
+ header: 'Reward',
+ width: proportional(2),
+ renderCell: (row) => (
+
+
+ {row.name}
+
+
+
+ ),
+ },
+ {
+ key: 'costLyt',
+ header: 'Cost',
+ width: pixel(104),
+ align: 'end',
+ renderCell: (row) => {formatLyt(row.costLyt)},
+ },
+ {
+ key: 'claimed',
+ header: 'Claimed',
+ width: pixel(104),
+ align: 'end',
+ renderCell: (row) => {formatCompact(row.claimed)},
+ },
+ {
+ key: 'used',
+ header: 'Redeemed',
+ width: pixel(112),
+ align: 'end',
+ renderCell: (row) => {formatCompact(row.used)},
+ },
+ {
+ key: 'rate',
+ header: 'Rate',
+ width: pixel(112),
+ align: 'end',
+ renderCell: (row) => (
+
+
+
+ ),
+ },
+ {
+ key: 'outstanding',
+ header: 'Outstanding',
+ width: pixel(140),
+ align: 'end',
+ renderCell: (row) => (
+ {formatLyt(row.outstanding)}
+ ),
+ },
+ ];
+
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(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 (
+
+ );
+ }}
+
+ );
+}
diff --git a/src/features/lyts/hooks/useLyts.ts b/src/features/lyts/hooks/useLyts.ts
new file mode 100644
index 0000000..031b669
--- /dev/null
+++ b/src/features/lyts/hooks/useLyts.ts
@@ -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]);
+}
diff --git a/src/features/lyts/services/lytsService.ts b/src/features/lyts/services/lytsService.ts
new file mode 100644
index 0000000..0b18c6b
--- /dev/null
+++ b/src/features/lyts/services/lytsService.ts
@@ -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);
+}
diff --git a/src/features/lyts/utils/rewardStatus.ts b/src/features/lyts/utils/rewardStatus.ts
new file mode 100644
index 0000000..733516d
--- /dev/null
+++ b/src/features/lyts/utils/rewardStatus.ts
@@ -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.
+ */
diff --git a/src/features/settings/components/ApiWebhooksManager.tsx b/src/features/settings/components/ApiWebhooksManager.tsx
index 09312b9..f9cc815 100644
--- a/src/features/settings/components/ApiWebhooksManager.tsx
+++ b/src/features/settings/components/ApiWebhooksManager.tsx
@@ -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 {
id: string;
@@ -38,73 +45,10 @@ export interface WebhookLogItem extends Record {
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(INITIAL_KEYS);
- const [webhooks, setWebhooks] = useState(INITIAL_WEBHOOKS);
+ const [keys, setKeys] = useState(SAMPLE_API_KEYS);
+ const [webhooks, setWebhooks] = useState(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() {
>
diff --git a/src/features/settings/components/BillingOverview.tsx b/src/features/settings/components/BillingOverview.tsx
index aeb8908..f6c1d47 100644
--- a/src/features/settings/components/BillingOverview.tsx
+++ b/src/features/settings/components/BillingOverview.tsx
@@ -26,36 +26,13 @@ export interface InvoiceItem extends Record {
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() {
diff --git a/src/features/settings/components/BusinessForm.tsx b/src/features/settings/components/BusinessForm.tsx
index 863317b..f659c91 100644
--- a/src/features/settings/components/BusinessForm.tsx
+++ b/src/features/settings/components/BusinessForm.tsx
@@ -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(DEFAULT_BUSINESS);
+ const [form, setForm] = useState(SAMPLE_BUSINESS);
const [isSaving, setIsSaving] = useState(false);
- const [savedData, setSavedData] = useState(DEFAULT_BUSINESS);
+ const [savedData, setSavedData] = useState(SAMPLE_BUSINESS);
const set = (key: K, val: BusinessData[K]) =>
setForm((f) => ({...f, [key]: val}));
diff --git a/src/features/settings/components/IntegrationsGrid.tsx b/src/features/settings/components/IntegrationsGrid.tsx
index c0078cd..33e0194 100644
--- a/src/features/settings/components/IntegrationsGrid.tsx
+++ b/src/features/settings/components/IntegrationsGrid.tsx
@@ -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(INITIAL_APPS);
+ const [apps, setApps] = useState(SAMPLE_INTEGRATIONS);
const [selectedApp, setSelectedApp] = useState(null);
const [apiKeyInput, setApiKeyInput] = useState('');
diff --git a/src/features/settings/components/SecurityManager.tsx b/src/features/settings/components/SecurityManager.tsx
index 57cf01c..d85c868 100644
--- a/src/features/settings/components/SecurityManager.tsx
+++ b/src/features/settings/components/SecurityManager.tsx
@@ -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 {
id: string;
@@ -36,33 +37,6 @@ export interface LoginAuditItem extends Record {
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() {
diff --git a/src/features/staff/components/AttendanceChart.tsx b/src/features/staff/components/AttendanceChart.tsx
new file mode 100644
index 0000000..17a7f71
--- /dev/null
+++ b/src/features/staff/components/AttendanceChart.tsx
@@ -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;
+}) {
+ return (
+
+ {(d) => (
+
+ )}
+
+ );
+}
diff --git a/src/features/staff/components/Leaderboard.tsx b/src/features/staff/components/Leaderboard.tsx
new file mode 100644
index 0000000..2939dd3
--- /dev/null
+++ b/src/features/staff/components/Leaderboard.tsx
@@ -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 {
+ 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;
+}) {
+ // Resolved from the workspace store directory, not from a fixture — see
+ // useStoreDirectory.
+ const storeName = useStoreName();
+
+ const columns: TableColumn[] = [
+ {
+ key: 'rank',
+ header: '#',
+ width: pixel(48),
+ renderCell: (row) => (
+
+ {row.rank}
+
+ ),
+ },
+ {
+ key: 'name',
+ header: 'Team member',
+ width: proportional(2),
+ renderCell: (row) => (
+
+
+
+
+ {row.name}
+
+
+ {row.role} · {storeName(row.storeId)}
+
+
+
+ ),
+ },
+ {
+ key: 'attendance',
+ header: 'Today',
+ width: pixel(112),
+ renderCell: (row) => (
+
+ ),
+ },
+ {
+ key: 'salesCount',
+ header: 'Sales',
+ width: pixel(88),
+ align: 'end',
+ renderCell: (row) => (
+ {formatCompact(row.salesCount)}
+ ),
+ },
+ {
+ key: 'rewardsIssued',
+ header: 'Rewards',
+ width: pixel(96),
+ align: 'end',
+ renderCell: (row) => (
+ {formatCompact(row.rewardsIssued)}
+ ),
+ },
+ {
+ key: 'punctualityPct',
+ header: 'Punctuality',
+ width: pixel(112),
+ align: 'end',
+ renderCell: (row) => (
+ {formatPct(row.punctualityPct, 0)}
+ ),
+ },
+ {
+ 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) => (
+
+
+
+ {row.performanceScore}
+
+
+ ),
+ },
+ ];
+
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(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 (
+
+ );
+ }}
+
+ );
+}
diff --git a/src/features/staff/components/StaffCard.tsx b/src/features/staff/components/StaffCard.tsx
new file mode 100644
index 0000000..170f069
--- /dev/null
+++ b/src/features/staff/components/StaffCard.tsx
@@ -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 (
+
+
+
+
+ {member.role} · {storeName(member.storeId)}
+
+ }
+ actions={
+
+ }
+ />
+
+
+
+
+
+
+
+ Performance
+
+
+ {member.performanceScore}/100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/staff/components/StaffGrid.tsx b/src/features/staff/components/StaffGrid.tsx
new file mode 100644
index 0000000..7d008ba
--- /dev/null
+++ b/src/features/staff/components/StaffGrid.tsx
@@ -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}) {
+ const [query, setQuery] = useState('');
+ const [filter, setFilter] = useState('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 (
+
+ 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'}
+ />
+
+ }
+ empty={
+
+ }
+ >
+ {() =>
+ visible.length === 0 ? (
+
+ ) : (
+
+ {visible.map((m) => (
+
+ ))}
+
+ )
+ }
+
+
+ );
+}
diff --git a/src/features/staff/components/StaffKpis.tsx b/src/features/staff/components/StaffKpis.tsx
new file mode 100644
index 0000000..f47cf4e
--- /dev/null
+++ b/src/features/staff/components/StaffKpis.tsx
@@ -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 (
+
+ {/* Padding and gaps match MetricCard so this row is the same height as
+ the dashboard KPI row (~112px). */}
+
+
+
+
+
+ {label}
+
+
+ {/* Text, not Heading — a count is data, not a section title. */}
+
+ String(Math.round(v))}
+ />
+
+
+ {formatPct(share, 0)} of {total} rostered
+
+
+
+
+ );
+}
+
+export function StaffKpis({resource}: {resource: Resource}) {
+ const columns = useMetricColumns();
+
+ return (
+ }
+ >
+ {(s) => (
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/staff/hooks/useStaff.ts b/src/features/staff/hooks/useStaff.ts
new file mode 100644
index 0000000..6e6ea09
--- /dev/null
+++ b/src/features/staff/hooks/useStaff.ts
@@ -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]);
+}
diff --git a/src/features/staff/services/staffService.ts b/src/features/staff/services/staffService.ts
new file mode 100644
index 0000000..c49a8ce
--- /dev/null
+++ b/src/features/staff/services/staffService.ts
@@ -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 {
+ const counts: Record = {
+ all: staff.length,
+ present: 0,
+ late: 0,
+ absent: 0,
+ leave: 0,
+ };
+ for (const member of staff) counts[member.attendance] += 1;
+ return counts;
+}
diff --git a/src/features/stores/components/StoreCard.tsx b/src/features/stores/components/StoreCard.tsx
new file mode 100644
index 0000000..e537001
--- /dev/null
+++ b/src/features/stores/components/StoreCard.tsx
@@ -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 (
+
+
+
+
+
+
+ {store.staffCount} staff
+
+
+ }
+ actions={}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/stores/components/StoreGrid.tsx b/src/features/stores/components/StoreGrid.tsx
new file mode 100644
index 0000000..52818ed
--- /dev/null
+++ b/src/features/stores/components/StoreGrid.tsx
@@ -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}) {
+ const [query, setQuery] = useState('');
+ const [filter, setFilter] = useState('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 = {
+ 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 (
+
+ 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'}
+ />
+
+ }
+ empty={
+
+ }
+ >
+ {() =>
+ visible.length === 0 ? (
+ // A filter returning nothing is a different situation from having
+ // no stores at all, and needs a different way out.
+
+ ) : (
+
+ {visible.map((s) => (
+
+ ))}
+
+ )
+ }
+
+
+ );
+}
diff --git a/src/features/stores/hooks/useStores.ts b/src/features/stores/hooks/useStores.ts
index 9c32737..8253269 100644
--- a/src/features/stores/hooks/useStores.ts
+++ b/src/features/stores/hooks/useStores.ts
@@ -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]);
}
diff --git a/src/features/stores/repositories/storeRepository.ts b/src/features/stores/repositories/storeRepository.ts
deleted file mode 100644
index 3b7d450..0000000
--- a/src/features/stores/repositories/storeRepository.ts
+++ /dev/null
@@ -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 => scopedEndpoint('/api/stores', scope),
-
- byId: (scope: Scope, storeId: string): Endpoint =>
- scopedEndpoint(`/api/stores/${storeId}`, scope),
-};
diff --git a/src/services/api/range.ts b/src/services/api/range.ts
index 9f5df17..0c5c15b 100644
--- a/src/services/api/range.ts
+++ b/src/services/api/range.ts
@@ -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';
diff --git a/src/shared/components/charts/CalendarHeatmap.tsx b/src/shared/components/charts/CalendarHeatmap.tsx
new file mode 100644
index 0000000..6133245
--- /dev/null
+++ b/src/shared/components/charts/CalendarHeatmap.tsx
@@ -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 ;
+}
+
+/**
+ * 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();
+ 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 (
+
+
+ {/* Weekday gutter: a header-height spacer, then one slot per row. */}
+
+
+ {DAYS.map((d, i) => (
+
+
+ {i % 2 === 0 && i < 6 ? d : ''}
+
+
+ ))}
+
+
+ {/* Right-aligned and clipped: overflow falls off the OLD end. */}
+
+ {Array.from({length: weeks}, (_, w) => {
+ const weekStart = start + w * 7;
+ const newMonth = w === 0 || monthOf(weekStart) !== monthOf(weekStart - 7);
+ return (
+
+
+
+ {newMonth && w > 0 ? MONTHS[monthOf(weekStart)] : ''}
+
+
+ {DAYS.map((dayLabel, d) => {
+ const n = weekStart + d;
+ if (n < first || n > last) {
+ return last ? '' : tint(-1)} />;
+ }
+ const value = byDay.get(n) ?? 0;
+ return (
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+ Less
+
+ {[-1, ...ramp.keys()].map((step) => (
+
+ ))}
+
+ More
+
+
+
+ );
+}
diff --git a/src/shared/components/patterns/MetricCard.tsx b/src/shared/components/patterns/MetricCard.tsx
index 40702f7..d64f2b8 100644
--- a/src/shared/components/patterns/MetricCard.tsx
+++ b/src/shared/components/patterns/MetricCard.tsx
@@ -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({
-
-
-
- {footer ?? (
+
+
+ {value === null ? (
+
+ —
+
+ ) : (
+
+ )}
+
+ {caption ? (
+
+ {caption}
+
+ ) : null}
+
+ {value === null && unavailableNote ? (
+
+ {unavailableNote}
+
+ ) : footer ?? (
{trend && trend.length > 1 ? (
diff --git a/src/shared/components/patterns/SectionHeader.tsx b/src/shared/components/patterns/SectionHeader.tsx
index 5877dfd..59b2842 100644
--- a/src/shared/components/patterns/SectionHeader.tsx
+++ b/src/shared/components/patterns/SectionHeader.tsx
@@ -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 , every panel says so — see shared/mocks.
+ const isSample = useInSampleScope();
return (
@@ -44,8 +48,12 @@ export function SectionHeader({
subtitle
)}
- {actions ? (
+ {actions || isSample ? (
+
{actions}
) : null}
diff --git a/src/shared/components/scope/DateTabs.tsx b/src/shared/components/scope/DateTabs.tsx
new file mode 100644
index 0000000..47f74a6
--- /dev/null
+++ b/src/shared/components/scope/DateTabs.tsx
@@ -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 (
+
+ {
+ if (v === 'custom') setIsPicking(true);
+ else setRange(v as RangeKey);
+ }}
+ >
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+ {range === 'custom' ? (
+ }
+ onClick={() => setIsPicking(true)}
+ />
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/shared/hooks/useScope.ts b/src/shared/hooks/useScope.ts
index 9d747ed..8010fa5 100644
--- a/src/shared/hooks/useScope.ts
+++ b/src/shared/hooks/useScope.ts
@@ -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],
+ );
}
diff --git a/src/shared/layouts/workspace/nav-config.ts b/src/shared/layouts/workspace/nav-config.ts
index 1de8eea..6dfa022 100644
--- a/src/shared/layouts/workspace/nav-config.ts
+++ b/src/shared/layouts/workspace/nav-config.ts
@@ -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. */
diff --git a/src/shared/mocks/SampleNotice.tsx b/src/shared/mocks/SampleNotice.tsx
new file mode 100644
index 0000000..d1432bf
--- /dev/null
+++ b/src/shared/mocks/SampleNotice.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import {Banner} from '@astryxdesign/core/Banner';
+
+/**
+ * TEMPORARY — the page-level half of a : 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 ;
+}
diff --git a/src/shared/mocks/SampleScope.tsx b/src/shared/mocks/SampleScope.tsx
new file mode 100644
index 0000000..a20ccb1
--- /dev/null
+++ b/src/shared/mocks/SampleScope.tsx
@@ -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={}` 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 {children};
+}
+
+export function useInSampleScope(): boolean {
+ return useContext(SampleContext);
+}
diff --git a/src/shared/mocks/SampleTag.tsx b/src/shared/mocks/SampleTag.tsx
index cbc87e5..d2fbcfa 100644
--- a/src/shared/mocks/SampleTag.tsx
+++ b/src/shared/mocks/SampleTag.tsx
@@ -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 ? (
-
+
) : null;
}
diff --git a/src/shared/mocks/sampleData.ts b/src/shared/mocks/sampleData.ts
new file mode 100644
index 0000000..cb7f4d0
--- /dev/null
+++ b/src/shared/mocks/sampleData.ts
@@ -0,0 +1,1952 @@
+/**
+ * ALL SAMPLE DATA, IN ONE FILE.
+ *
+ * Every mock, fixture, dummy value and hardcoded example figure the console
+ * renders lives here and nowhere else. Nothing in this file is served over
+ * HTTP — there are no fake API routes. Pages read it through
+ * `useSampleResource()` (shared/mocks/withSample.ts), and every panel that
+ * shows it wears the "Sample data" token.
+ *
+ * ── Connecting a real API ────────────────────────────────────────────────
+ * 1. Add the endpoint to the feature's repository and read it with
+ * useResource() in that feature's hook, in place of useSampleResource().
+ * 2. Delete the builder it replaces from this file.
+ * 3. When the last builder goes, delete this file and shared/mocks/; the
+ * compiler then lists every remaining / to drop.
+ *
+ * Values are deterministic (seeded, no Math.random) so SSR and hydration
+ * agree and a scope change moves the numbers the way real data would.
+ */
+
+import type {
+ ActivityEvent,
+ Granularity,
+ HourCell,
+ Insight,
+ Kpi,
+ PeriodPoint,
+ RewardUsagePoint,
+ StoreComparison,
+ TimePoint,
+} from '@/features/dashboard/types/dashboard';
+import type {
+ ActivityId,
+ ActivityMetric,
+ CampaignSummary,
+ JourneyStage,
+} from '@/features/dashboard/types/intelligence';
+import type {
+ ConversionReport,
+ FootfallReport,
+ ReportBucket,
+} from '@/features/dashboard/types/reports';
+import type {
+ ActivityRow,
+ Campaign,
+ Journey,
+} from '@/features/engagement/types/engagement';
+import type {
+ Reward,
+ RewardStatus,
+} from '@/features/lyts/types/reward';
+import type {
+ Attendance,
+ AttendancePoint,
+ StaffMember,
+ StaffSummary,
+} from '@/features/staff/types/staff';
+import type {
+ Store,
+} from '@/features/stores/types/store';
+import type {InsightCardItem} from '@/features/loyaly-ai/components/EmptyState';
+import type {ApiKeyItem, WebhookEndpointItem, WebhookLogItem} from '@/features/settings/components/ApiWebhooksManager';
+import type {InvoiceItem} from '@/features/settings/components/BillingOverview';
+import type {BusinessData} from '@/features/settings/components/BusinessForm';
+import type {IntegrationApp} from '@/features/settings/components/IntegrationsGrid';
+import type {LoginAuditItem} from '@/features/settings/components/SecurityManager';
+import type {
+ RangeKey,
+} from '@/shared/types/api';
+import type {Sale} from '@/features/commerce/types/sale';
+import {DEFAULT_TZ, shiftDay, shopToday, toReportWindow} from '@/services/api/range';
+
+// ===========================================================================
+// Seeded randomness
+// (was shared/mocks/rng.ts)
+// ===========================================================================
+
+/**
+ * Seeded, deterministic pseudo-randomness.
+ *
+ * Every generator derives its seed from the store id and the metric name, so
+ * the same request always returns the same numbers. That matters more than it
+ * sounds: when the whole point of this phase is judging a design, numbers that
+ * reshuffle on every reload make it impossible to tell a layout change from a
+ * data change — and it keeps screenshots stable.
+ */
+
+export function hashSeed(...parts: (string | number)[]): number {
+ let h = 2166136261;
+ for (const part of parts) {
+ const s = String(part);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ }
+ return h >>> 0;
+}
+
+/** mulberry32 — small, fast, good enough for fixtures. */
+export function mulberry32(seed: number): () => number {
+ let a = seed;
+ return function next() {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function createRng(...parts: (string | number)[]) {
+ const next = mulberry32(hashSeed(...parts));
+ return {
+ next,
+ /** Integer in [min, max]. */
+ int: (min: number, max: number) =>
+ Math.floor(next() * (max - min + 1)) + min,
+ /** Float in [min, max). */
+ float: (min: number, max: number) => next() * (max - min) + min,
+ pick: (arr: readonly T[]): T => arr[Math.floor(next() * arr.length)],
+ };
+}
+
+// ===========================================================================
+// Stores — the fixture roster every sample shares
+// (was features/stores/mocks/stores.mock.ts)
+// ===========================================================================
+
+/**
+ * The store roster. Names, staffing and status are fixed so the switcher, the
+ * comparison panel and the Store module all agree; the numbers derive from the
+ * shared timeseries so a store's card and its dashboard cannot disagree.
+ */
+export const STORE_SEED: {
+ id: string;
+ name: string;
+ city: string;
+ staffCount: number;
+ status: Store['status'];
+ /**
+ * Traffic multiplier relative to a typical store. This is the ONLY place a
+ * store's size is defined — every generator multiplies by it, so a store's
+ * dashboard, its comparison card and its row in the Store module cannot
+ * disagree about how big it is.
+ *
+ * The spread is deliberate: the flagship genuinely outperforms and
+ * Whitefield genuinely struggles (it's also the one Loyaly AI flags).
+ * Fixtures that are uniformly average make a comparison view look like it
+ * works while showing nothing.
+ */
+ scale: number;
+}[] = [
+ {
+ id: 'blr-indiranagar',
+ name: 'Indiranagar Flagship',
+ city: 'Bengaluru',
+ staffCount: 14,
+ status: 'open',
+ scale: 1.6,
+ },
+ {
+ id: 'blr-koramangala',
+ name: 'Koramangala',
+ city: 'Bengaluru',
+ staffCount: 9,
+ status: 'open',
+ scale: 1.0,
+ },
+ {
+ id: 'blr-whitefield',
+ name: 'Whitefield',
+ city: 'Bengaluru',
+ staffCount: 7,
+ status: 'maintenance',
+ scale: 0.62,
+ },
+ {
+ id: 'che-anna-nagar',
+ name: 'Anna Nagar',
+ city: 'Chennai',
+ staffCount: 8,
+ status: 'open',
+ scale: 0.85,
+ },
+ {
+ id: 'hyd-jubilee',
+ name: 'Jubilee Hills',
+ city: 'Hyderabad',
+ staffCount: 11,
+ status: 'closed',
+ scale: 1.15,
+ },
+];
+
+export function storeName(id: string): string {
+ return STORE_SEED.find((s) => s.id === id)?.name ?? 'All stores';
+}
+
+export function storeExists(id: string): boolean {
+ return STORE_SEED.some((s) => s.id === id);
+}
+
+/** Traffic multiplier for a store id; 'all' is the sum of every store. */
+export function storeScale(id: string): number {
+ if (id === 'all') {
+ return STORE_SEED.reduce((a, s) => a + s.scale, 0);
+ }
+ return STORE_SEED.find((s) => s.id === id)?.scale ?? 1;
+}
+
+/**
+ * Totals per store, derived from the SAME buildTimeseries() the dashboard
+ * charts use. Generating these independently is how a store's card ends up
+ * claiming a different revenue than the store's own dashboard.
+ */
+export function buildStores(range: RangeKey, endMs: number): Store[] {
+ return STORE_SEED.map((s) => {
+ const points = buildTimeseries(range, s.id, endMs);
+ const visitors = points.reduce((a, p) => a + p.visitors, 0);
+ const purchases = points.reduce((a, p) => a + p.purchases, 0);
+
+ return {
+ id: s.id,
+ name: s.name,
+ status: s.status,
+ visitors,
+ purchases,
+ revenueInr: points.reduce((a, p) => a + p.revenue, 0),
+ conversionPct: visitors
+ ? Number(((purchases / visitors) * 100).toFixed(1))
+ : 0,
+ staffCount: s.staffCount,
+ };
+ });
+}
+
+export function buildStore(
+ id: string,
+ range: RangeKey,
+ endMs: number,
+): Store | null {
+ return buildStores(range, endMs).find((s) => s.id === id) ?? null;
+}
+
+// ===========================================================================
+// Dashboard — timeseries, KPIs, peak hours, activity feed
+// (was features/dashboard/mocks/dashboard.mock.ts)
+// ===========================================================================
+
+export const RANGE_DAYS: Record = {
+ today: 1,
+ '7d': 7,
+ '30d': 30,
+ '90d': 90,
+ mtd: 21,
+ ytd: 120,
+ // Placeholder. Query only carries the RangeKey, so a custom window's real
+ // span is not visible to the generators — swap for (end - start) in days
+ // once parseQuery threads the dates through.
+ custom: 30,
+};
+
+/**
+ * Fixtures are shaped, not uniform. Retail footfall has a weekly rhythm —
+ * weekends run hot, Tuesdays run cold — and a dashboard rendered against flat
+ * noise looks fine while hiding the fact that nothing reads as a trend. These
+ * curves make the charts honest to look at.
+ */
+function dayFactor(dayOfWeek: number): number {
+ // 0 = Mon … 6 = Sun
+ return [0.92, 0.86, 0.95, 1.05, 1.25, 1.4, 1.18][dayOfWeek];
+}
+
+export function buildTimeseries(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): TimePoint[] {
+ const days = RANGE_DAYS[range];
+ // The window's end day is part of the seed. Without it, the current and
+ // previous periods draw the identical random sequence, so every KPI delta
+ // collapses into an artifact of weekday alignment — and all four move the
+ // same direction at once, which is exactly what a real dashboard never does.
+ // Bucketing to the day keeps it deterministic within a day.
+ const endDay = Math.floor(endMs / 86400000);
+ const rng = createRng('timeseries', storeId, range, endDay);
+ // Scale comes from the store roster, not a flat constant. With a flat base
+ // every store produced near-identical numbers and the comparison view's
+ // "Top"/"Lowest" badges were effectively random.
+ const base = 180 * storeScale(storeId);
+
+ return Array.from({length: days}, (_, i) => {
+ const date = new Date(endMs - (days - 1 - i) * 86400000);
+ const dow = (date.getUTCDay() + 6) % 7;
+ // Mild upward drift so a 90-day view has something to say.
+ const drift = 1 + (i / days) * 0.18;
+ const visitors = Math.round(
+ base * dayFactor(dow) * drift * rng.float(0.92, 1.08),
+ );
+ const convRate = rng.float(0.17, 0.27);
+ const purchases = Math.round(visitors * convRate);
+ const avgBasket = rng.float(280, 520);
+
+ return {
+ t: date.toISOString().slice(0, 10),
+ visitors,
+ purchases,
+ revenue: Math.round(purchases * avgBasket),
+ conversion: Number((convRate * 100).toFixed(1)),
+ };
+ });
+}
+
+function sum(points: TimePoint[], key: keyof TimePoint): number {
+ return points.reduce((a, p) => a + (p[key] as number), 0);
+}
+
+export function buildKpis(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): Kpi[] {
+ const days = RANGE_DAYS[range];
+ const current = buildTimeseries(range, storeId, endMs);
+ // The comparison window is the immediately preceding period of equal length,
+ // which is what "vs previous" has to mean for the delta to be meaningful.
+ const previous = buildTimeseries(range, storeId, endMs - days * 86400000);
+
+ const delta = (a: number, b: number) =>
+ b === 0 ? 0 : Number((((a - b) / b) * 100).toFixed(1));
+
+ const trend = (key: keyof TimePoint) =>
+ current.slice(-14).map((p) => ({t: p.t, v: p[key] as number}));
+
+ const conversionPct = (p: TimePoint[]) => {
+ const v = sum(p, 'visitors');
+ return v === 0 ? 0 : Number(((sum(p, 'purchases') / v) * 100).toFixed(1));
+ };
+
+ return [
+ {
+ id: 'visitors',
+ label: 'Visitors',
+ value: sum(current, 'visitors'),
+ unit: 'count',
+ deltaPct: delta(sum(current, 'visitors'), sum(previous, 'visitors')),
+ isRiseGood: true,
+ trend: trend('visitors'),
+ },
+ {
+ id: 'purchases',
+ label: 'Purchases',
+ value: sum(current, 'purchases'),
+ unit: 'count',
+ deltaPct: delta(sum(current, 'purchases'), sum(previous, 'purchases')),
+ isRiseGood: true,
+ trend: trend('purchases'),
+ },
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ value: sum(current, 'revenue'),
+ unit: 'inr',
+ deltaPct: delta(sum(current, 'revenue'), sum(previous, 'revenue')),
+ isRiseGood: true,
+ trend: trend('revenue'),
+ },
+ {
+ id: 'conversion',
+ label: 'Conversion',
+ value: conversionPct(current),
+ unit: 'pct',
+ deltaPct: delta(conversionPct(current), conversionPct(previous)),
+ isRiseGood: true,
+ trend: trend('conversion'),
+ },
+ ];
+}
+
+export function buildPeakHours(storeId: string): HourCell[] {
+ const rng = createRng('peak', storeId);
+ const cells: HourCell[] = [];
+ for (let day = 0; day < 7; day++) {
+ for (let hour = 0; hour < 24; hour++) {
+ // Shops are shut overnight — a heatmap that glows at 04:00 is fiction.
+ if (hour < 8 || hour > 22) {
+ cells.push({day, hour, value: 0});
+ continue;
+ }
+ // Two humps: lunch and evening, with the evening peak dominant.
+ const lunch = Math.exp(-((hour - 13) ** 2) / 4);
+ const evening = Math.exp(-((hour - 19) ** 2) / 6) * 1.6;
+ const value = Math.round(
+ (lunch + evening) * dayFactor(day) * 40 * rng.float(0.85, 1.15),
+ );
+ cells.push({day, hour, value});
+ }
+ }
+ return cells;
+}
+
+const ACTIVITY: {kind: ActivityEvent['kind']; title: string; detail: string}[] =
+ [
+ {
+ kind: 'reward_redeemed',
+ title: 'Free Coffee redeemed',
+ detail: '120 LYTs · Indiranagar Flagship',
+ },
+ {
+ kind: 'purchase',
+ title: 'Purchase completed',
+ detail: '₹840 · 2 items · Koramangala',
+ },
+ {
+ kind: 'staff_checked_in',
+ title: 'Anita R checked in',
+ detail: 'Morning shift · Whitefield',
+ },
+ {
+ kind: 'reward_expired',
+ title: 'Combo 20% expired',
+ detail: 'Claimed 214 times · used 61',
+ },
+ {
+ kind: 'store_opened',
+ title: 'Jubilee Hills opened',
+ detail: '09:00 · 3 staff on shift',
+ },
+ ];
+
+/**
+ * 48 events, not 12.
+ *
+ * The dashboard shows six of these and the Activity page shows the rest, so
+ * the fixture has to be long enough for "View all" to lead somewhere. Callers
+ * cap what they render — the endpoint returns the history.
+ *
+ * The gap is ACCUMULATED rather than drawn per index. `endMs - i * rng.int(…)`
+ * redraws the spacing on every row, so row 3 could land closer to now than
+ * row 2 and the feed arrived out of order — invisible at 12 rows on a panel
+ * with no ordering claim, obvious on a page headed "newest first".
+ */
+export function buildActivity(storeId: string, endMs: number): ActivityEvent[] {
+ const rng = createRng('activity', storeId);
+ let minutesAgo = 0;
+
+ return Array.from({length: 48}, (_, i) => {
+ const tpl = ACTIVITY[i % ACTIVITY.length];
+ if (i > 0) minutesAgo += rng.int(6, 40);
+ return {
+ id: `a${i}`,
+ at: new Date(endMs - minutesAgo * 60000).toISOString(),
+ kind: tpl.kind,
+ title: tpl.title,
+ detail: tpl.detail,
+ storeId,
+ };
+ });
+}
+
+// ===========================================================================
+// Dashboard — store comparison, reward usage, period rollup
+// (was features/dashboard/mocks/analytics.mock.ts)
+// ===========================================================================
+
+/**
+ * Per-store totals for the comparison small-multiples.
+ *
+ * Built from the SAME buildTimeseries() the main charts use, so a store's
+ * comparison bar and its own dashboard cannot disagree — the alternative
+ * (independent generators) produces fixtures that quietly contradict each
+ * other and make real bugs impossible to spot.
+ */
+export function buildStoreComparison(
+ range: RangeKey,
+ endMs: number,
+): StoreComparison[] {
+ return STORE_SEED.map((s) => {
+ const points = buildTimeseries(range, s.id, endMs);
+ const visitors = points.reduce((a, p) => a + p.visitors, 0);
+ const purchases = points.reduce((a, p) => a + p.purchases, 0);
+ return {
+ storeId: s.id,
+ name: s.name,
+ visitors,
+ purchases,
+ revenueInr: points.reduce((a, p) => a + p.revenue, 0),
+ conversionPct: visitors
+ ? Number(((purchases / visitors) * 100).toFixed(1))
+ : 0,
+ trend: points.slice(-14).map((p) => ({t: p.t, v: p.visitors})),
+ };
+ });
+}
+
+const REWARDS = [
+ {id: 'r-coffee', name: 'Free Coffee'},
+ {id: 'r-combo20', name: 'Combo 20% off'},
+ {id: 'r-weekend', name: 'Weekend Bonus'},
+ {id: 'r-birthday', name: 'Birthday Treat'},
+ {id: 'r-bogo', name: 'Buy 1 Get 1'},
+ {id: 'r-dessert', name: 'Dessert on us'},
+];
+
+/**
+ * Claim-vs-use. The gap between the two is the actual story on this chart:
+ * a reward claimed 800 times and used 90 is a liability, not a success, and
+ * the fixtures are shaped so that story is visible.
+ */
+export function buildRewardUsage(
+ storeId: string,
+ range: RangeKey,
+): RewardUsagePoint[] {
+ return REWARDS.map((r, i) => {
+ const rng = createRng('reward-usage', r.id, storeId, range);
+ const claimed = Math.round(rng.int(120, 900) * (storeId === 'all' ? 1 : 0.3));
+ // Redemption rates vary widely by reward type — deliberately spread so
+ // "most claimed" and "least used" are different rewards.
+ const rate = [0.72, 0.34, 0.81, 0.55, 0.19, 0.44][i];
+ const used = Math.round(claimed * rate * rng.float(0.9, 1.1));
+ return {
+ rewardId: r.id,
+ name: r.name,
+ claimed,
+ used,
+ usageRatePct: claimed ? Number(((used / claimed) * 100).toFixed(1)) : 0,
+ };
+ }).sort((a, b) => b.claimed - a.claimed);
+}
+
+const MONTHS = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+];
+
+/**
+ * Weekly / monthly rollup behind the SegmentedControl.
+ *
+ * Buckets are derived from the daily series rather than generated fresh, so
+ * "last 30 days" and "the weeks inside it" always sum to the same totals.
+ */
+export function buildPeriodPerformance(
+ granularity: Granularity,
+ storeId: string,
+ endMs: number,
+): PeriodPoint[] {
+ // Always pull a long window; the granularity decides how it is grouped.
+ const range: RangeKey = granularity === 'weekly' ? '90d' : 'ytd';
+ const points = buildTimeseries(range, storeId, endMs);
+
+ const buckets = new Map();
+ for (const p of points) {
+ const d = new Date(p.t);
+ const label =
+ granularity === 'weekly'
+ ? `W${isoWeek(d)}`
+ : MONTHS[d.getUTCMonth()];
+
+ const existing = buckets.get(label) ?? {
+ label,
+ visitors: 0,
+ purchases: 0,
+ revenue: 0,
+ };
+ existing.visitors += p.visitors;
+ existing.purchases += p.purchases;
+ existing.revenue += p.revenue;
+ buckets.set(label, existing);
+ }
+
+ const out = [...buckets.values()];
+ // Drop the leading bucket: a partial first week or month renders as a
+ // collapsed bar and reads as a crash rather than as missing days.
+ return out.length > 2 ? out.slice(1) : out;
+}
+
+function isoWeek(d: Date): number {
+ const target = new Date(
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
+ );
+ const dayNum = (target.getUTCDay() + 6) % 7;
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
+ const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
+ return (
+ 1 + Math.round((target.getTime() - firstThursday.getTime()) / 604800000)
+ );
+}
+
+// ===========================================================================
+// Dashboard — activity metrics and "What needs your attention"
+// (was features/dashboard/mocks/intelligence.mock.ts)
+// ===========================================================================
+
+/**
+ * Activity fixtures, DERIVED rather than invented.
+ *
+ * Every number here is anchored to buildTimeseries() — the same generator the
+ * Footfall and Revenue charts read. That is not tidiness: an activity layer
+ * generated independently would let the dashboard claim 1,248 visitors in one
+ * card and 3,400 visits in the card directly below it, and no merchant would
+ * trust either number again. Activity counts are a share of real visitors,
+ * journey stages are the real funnel, and campaign rows are slices of the
+ * activity they run on.
+ *
+ * The chain is also enforced by construction: customers ≤ interactions,
+ * repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can
+ * produce more purchases than participants would make the funnel UI render
+ * a widening cone, and the bug would look like a design problem.
+ */
+
+interface ActivitySeed {
+ id: ActivityId;
+ label: string;
+ description: string;
+ group: ActivityMetric['group'];
+ accent: ActivityMetric['accent'];
+ isFeatured: boolean;
+ /** Interactions per visitor. Above 1 means a visitor does it more than once. */
+ perVisitor: number;
+ /** Interactions per distinct customer — the repeat rate within the activity. */
+ intensity: number;
+ /** Fraction of participants who come back afterwards. */
+ returnRate: number;
+ /** Fraction of returning participants who then buy. */
+ buyRate: number;
+ /** Whether taking part can grant a reward. */
+ hasRewards: boolean;
+ /** LYTs granted per participating customer, on average. 0 where none. */
+ lytsPerCustomer: number;
+ status: ActivityMetric['status'];
+}
+
+/**
+ * The ecosystem, in reading order.
+ *
+ * Accents alternate deliberately and are fixed per activity: warm is what the
+ * brand gives the customer, cool is what the system measures back. Two hues
+ * across ten rows is what keeps this from becoming a colour-coded legend
+ * nobody can hold in their head.
+ *
+ * `isFeatured` is what the dashboard shows. Six is the ceiling — the summary
+ * exists to be glanced at, and ten small cards is a second dashboard.
+ */
+const CATALOG: ActivitySeed[] = [
+ {
+ id: 'walk',
+ label: 'Walk',
+ description: 'Passers-by detected near the store',
+ group: 'engagement',
+ accent: 'warm',
+ isFeatured: false,
+ perVisitor: 1.9,
+ intensity: 1.4,
+ returnRate: 0.11,
+ buyRate: 0.34,
+ hasRewards: false,
+ lytsPerCustomer: 0,
+ status: 'live',
+ },
+ {
+ id: 'visit',
+ label: 'Visit',
+ description: 'Customers who checked in at the store',
+ group: 'engagement',
+ accent: 'warm',
+ isFeatured: true,
+ perVisitor: 1.0,
+ intensity: 1.7,
+ returnRate: 0.31,
+ buyRate: 0.52,
+ hasRewards: false,
+ lytsPerCustomer: 12,
+ status: 'live',
+ },
+ {
+ id: 'selfie',
+ label: 'Selfie',
+ description: 'In-store photos shared to the feed',
+ group: 'engagement',
+ accent: 'cool',
+ isFeatured: true,
+ perVisitor: 0.3,
+ intensity: 1.8,
+ returnRate: 0.32,
+ buyRate: 0.47,
+ hasRewards: true,
+ lytsPerCustomer: 25,
+ status: 'live',
+ },
+ {
+ id: 'spin',
+ label: 'Spin',
+ description: 'Reward wheel plays',
+ group: 'engagement',
+ accent: 'warm',
+ isFeatured: true,
+ perVisitor: 0.58,
+ intensity: 1.76,
+ returnRate: 0.23,
+ buyRate: 0.43,
+ hasRewards: true,
+ lytsPerCustomer: 40,
+ status: 'live',
+ },
+ {
+ id: 'scratch',
+ label: 'Scratch',
+ description: 'Scratch cards opened',
+ group: 'engagement',
+ accent: 'cool',
+ isFeatured: false,
+ perVisitor: 0.36,
+ intensity: 1.5,
+ returnRate: 0.19,
+ buyRate: 0.38,
+ hasRewards: true,
+ lytsPerCustomer: 30,
+ status: 'live',
+ },
+ {
+ id: 'challenge',
+ label: 'Challenge',
+ description: 'Multi-step tasks completed',
+ group: 'engagement',
+ accent: 'cool',
+ isFeatured: true,
+ perVisitor: 0.17,
+ intensity: 1.2,
+ returnRate: 0.48,
+ buyRate: 0.55,
+ hasRewards: true,
+ lytsPerCustomer: 75,
+ status: 'live',
+ },
+ {
+ id: 'friend',
+ label: 'Referral',
+ description: 'Friends invited by existing customers',
+ group: 'growth',
+ accent: 'warm',
+ isFeatured: true,
+ perVisitor: 0.07,
+ intensity: 1.1,
+ returnRate: 0.42,
+ buyRate: 0.58,
+ hasRewards: true,
+ lytsPerCustomer: 120,
+ status: 'live',
+ },
+ {
+ id: 'event',
+ label: 'Event',
+ description: 'Attendance at in-store events',
+ group: 'growth',
+ accent: 'cool',
+ isFeatured: true,
+ perVisitor: 0.11,
+ intensity: 1.05,
+ returnRate: 0.37,
+ buyRate: 0.62,
+ hasRewards: false,
+ lytsPerCustomer: 50,
+ status: 'paused',
+ },
+ {
+ id: 'brand',
+ label: 'Brand campaign',
+ description: 'Reach from partner and brand pushes',
+ group: 'growth',
+ accent: 'warm',
+ isFeatured: false,
+ perVisitor: 0.24,
+ intensity: 1.3,
+ returnRate: 0.16,
+ buyRate: 0.29,
+ hasRewards: true,
+ lytsPerCustomer: 20,
+ status: 'draft',
+ },
+ {
+ id: 'shop',
+ label: 'Shop',
+ description: 'Catalogue browsing that ended in a basket',
+ group: 'commerce',
+ accent: 'cool',
+ isFeatured: false,
+ perVisitor: 0.26,
+ intensity: 1.15,
+ returnRate: 0.35,
+ buyRate: 0.71,
+ hasRewards: false,
+ lytsPerCustomer: 0,
+ status: 'live',
+ },
+];
+
+function totalVisitors(range: RangeKey, storeId: string, endMs: number): number {
+ return buildTimeseries(range, storeId, endMs).reduce(
+ (a, p) => a + p.visitors,
+ 0,
+ );
+}
+
+function totalPurchases(range: RangeKey, storeId: string, endMs: number): number {
+ return buildTimeseries(range, storeId, endMs).reduce(
+ (a, p) => a + p.purchases,
+ 0,
+ );
+}
+
+/**
+ * One activity's numbers for a scope.
+ *
+ * Every step is clamped to at least 1 below its parent once the parent is
+ * non-trivial, so the funnel narrows even for a quiet store where rounding
+ * would otherwise collapse two stages onto the same value and make a 100%
+ * conversion appear out of nowhere.
+ */
+function buildMetric(
+ seed: ActivitySeed,
+ visitors: number,
+ range: RangeKey,
+ storeId: string,
+): ActivityMetric {
+ const rng = createRng('activity-metric', seed.id, storeId, range);
+ const jitter = rng.float(0.88, 1.12);
+
+ const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter));
+ const customers = Math.max(1, Math.round(count / seed.intensity));
+ const repeatVisits = Math.round(customers * seed.returnRate);
+ const purchases = Math.round(repeatVisits * seed.buyRate);
+ const attributedRevenueInr = Math.round(purchases * rng.float(310, 540));
+
+ return {
+ id: seed.id,
+ label: seed.label,
+ description: seed.description,
+ group: seed.group,
+ accent: seed.accent,
+ count,
+ status: seed.status,
+ // Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who
+ // spins six times is not granted six rewards, and multiplying by `count`
+ // would inflate the liability by the intensity factor on every activity.
+ lytsIssued: Math.round(customers * seed.lytsPerCustomer),
+ // Compared against the previous period of equal length, same convention as
+ // the KPI deltas. Drawn rather than recomputed: the previous window's
+ // activity split is not something the timeseries carries.
+ deltaPct: Number(rng.float(-9, 24).toFixed(1)),
+ impact: {
+ customers,
+ rewardClaims: seed.hasRewards
+ ? Math.round(customers * rng.float(0.36, 0.62))
+ : undefined,
+ repeatVisits,
+ purchases,
+ attributedRevenueInr,
+ // Modelled, and says so. The whole chain above is derived from observed
+ // footfall through fixed rates — no purchase here is joined to a
+ // specific activity event, and the UI reads this field to disclose that.
+ attribution: 'estimated',
+ },
+ isFeatured: seed.isFeatured,
+ };
+}
+
+export function buildActivityMetrics(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): ActivityMetric[] {
+ const visitors = totalVisitors(range, storeId, endMs);
+ return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId));
+}
+
+/**
+ * The five-stage progression, built from the funnel that already exists.
+ *
+ * Visit and Purchase are NOT invented — they are the same visitor and purchase
+ * totals the KPI row shows, so the first and third stage of the journey and
+ * the first two KPI cards can never disagree. Engage and Return come from the
+ * activity layer, Refer is the referral activity's own count.
+ */
+export function buildJourney(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): JourneyStage[] {
+ const visitors = totalVisitors(range, storeId, endMs);
+ const purchases = totalPurchases(range, storeId, endMs);
+ const metrics = buildActivityMetrics(range, storeId, endMs);
+ const engaged = metrics
+ .filter((m) => m.group === 'engagement' && m.id !== 'walk')
+ .reduce((a, m) => Math.max(a, m.impact.customers), 0);
+
+ const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0;
+ const returned = Math.round(purchases * 0.69);
+
+ const raw: {id: JourneyStage['id']; label: string; value: number}[] = [
+ {id: 'visit', label: 'Visit', value: visitors},
+ // Engagement cannot exceed footfall, and on a quiet store the strongest
+ // single activity can round above it.
+ {id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)},
+ {id: 'purchase', label: 'Purchase', value: purchases},
+ {id: 'return', label: 'Return', value: returned},
+ {id: 'refer', label: 'Refer', value: Math.min(referrals, returned)},
+ ];
+
+ return raw.map((stage, i) => ({
+ ...stage,
+ conversionPct:
+ i === 0 || raw[i - 1].value === 0
+ ? undefined
+ : Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)),
+ }));
+}
+
+/** Campaign name and framing per activity. The numbers come from the activity. */
+const CAMPAIGNS: {
+ id: string;
+ name: string;
+ activityId: ActivityId;
+ status: CampaignSummary['status'];
+ /** Labels for the three funnel steps, in order. */
+ steps: [string, string, string];
+}[] = [
+ {
+ id: 'c-challenge',
+ name: 'Weekend Challenge',
+ activityId: 'challenge',
+ status: 'live',
+ steps: ['Participants', 'Repeat visits', 'Purchases'],
+ },
+ {
+ id: 'c-friend',
+ name: 'Refer a Friend',
+ activityId: 'friend',
+ status: 'live',
+ steps: ['Referrals', 'New customers', 'Purchases'],
+ },
+ {
+ id: 'c-event',
+ name: 'Saturday Event',
+ activityId: 'event',
+ status: 'ended',
+ steps: ['Attendees', 'Repeat visits', 'Purchases'],
+ },
+ {
+ id: 'c-spin',
+ name: 'Spin & Win',
+ activityId: 'spin',
+ status: 'live',
+ steps: ['Spins', 'Reward claims', 'Purchases'],
+ },
+];
+
+export function buildCampaigns(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): CampaignSummary[] {
+ const metrics = buildActivityMetrics(range, storeId, endMs);
+
+ return CAMPAIGNS.flatMap((c) => {
+ const m = metrics.find((x) => x.id === c.activityId);
+ if (!m) return [];
+
+ // The middle step differs by campaign shape: a referral drive converts to
+ // new customers, a spin converts to claimed rewards, everything else to a
+ // return visit. Reading it off the impact chain rather than drawing a
+ // fresh number is what keeps a campaign row consistent with the activity
+ // card above it.
+ const middle =
+ c.activityId === 'friend'
+ ? m.impact.customers
+ : c.activityId === 'spin'
+ ? (m.impact.rewardClaims ?? m.impact.repeatVisits)
+ : m.impact.repeatVisits;
+
+ return [
+ {
+ id: c.id,
+ name: c.name,
+ activityId: m.id,
+ accent: m.accent,
+ status: c.status,
+ steps: [
+ {label: c.steps[0], value: m.count},
+ {label: c.steps[1], value: middle},
+ {label: c.steps[2], value: m.impact.purchases},
+ ],
+ attributedRevenueInr: m.impact.attributedRevenueInr,
+ attribution: m.impact.attribution,
+ },
+ ];
+ });
+}
+
+const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100);
+
+/**
+ * Insights, computed from the numbers actually on screen.
+ *
+ * Deliberately not a static list of sentences. Every claim below is derived
+ * from the same fixtures the panels render, so an insight cannot contradict
+ * the card next to it — which is the failure mode that makes merchants stop
+ * reading an AI panel after the second week.
+ *
+ * Each one carries an action. An observation with no next step belongs in a
+ * chart, not in a section called "What needs your attention".
+ */
+export function buildInsights(
+ range: RangeKey,
+ storeId: string,
+ endMs: number,
+): Insight[] {
+ const metrics = buildActivityMetrics(range, storeId, endMs);
+ const by = (id: ActivityId) => metrics.find((m) => m.id === id)!;
+
+ const spin = by('spin');
+ const challenge = by('challenge');
+ const visit = by('visit');
+ const friend = by('friend');
+
+ const spinConversion = pct(spin.impact.purchases, spin.impact.customers);
+ const challengeReturn = pct(
+ challenge.impact.repeatVisits,
+ challenge.impact.customers,
+ );
+ const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers);
+ const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn;
+ const visitConversion = pct(visit.impact.purchases, visit.impact.customers);
+
+ const insights: Insight[] = [
+ {
+ id: 'i-spin',
+ severity: spinConversion < 15 ? 'warning' : 'info',
+ // The headline states the CONVERSION, not the direction of the count.
+ // "Spin engagement is up 16%" reads as good news and is the wrong thing
+ // to lead with — and it is also plainly wrong on a period where plays
+ // fell, which is how a generated insight loses a merchant's trust.
+ title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`,
+ body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`,
+ action: {label: 'Refresh reward catalogue', href: '/lyts'},
+ },
+ {
+ id: 'i-challenge',
+ severity: 'success',
+ title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`,
+ body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`,
+ action: {label: 'Plan another challenge', href: '/activity'},
+ },
+ {
+ id: 'i-visit',
+ severity: visitConversion < 20 ? 'warning' : 'info',
+ title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`,
+ body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`,
+ action: {label: 'Launch a visit offer', href: '/lyts'},
+ },
+ {
+ id: 'i-friend',
+ severity: 'info',
+ title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`,
+ body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`,
+ action: {label: 'Promote the referral reward', href: '/lyts'},
+ },
+ ];
+
+ // Most severe first, so the panel's top row is always the thing that most
+ // needs attention rather than whichever activity happens to be listed first.
+ const rank = {error: 0, warning: 1, success: 2, info: 3} as const;
+ return insights.sort((a, b) => rank[a.severity] - rank[b.severity]);
+}
+
+// ===========================================================================
+// LYTs — rewards, redemptions, programme activity
+// (was features/lyts/mocks/lyts.mock.ts)
+// ===========================================================================
+
+/**
+ * The reward catalogue.
+ *
+ * `usageRate` is fixed per reward rather than random, because the story this
+ * module has to tell is that redemption rates differ WILDLY by offer type —
+ * Buy-1-Get-1 is claimed constantly and redeemed rarely, a free coffee is
+ * claimed and used almost immediately. A catalogue where everything converts
+ * at ~50% would make "most claimed" and "least used" the same reward and the
+ * whole module pointless.
+ *
+ * 1 LYT = ₹1, so costLyt doubles as the rupee liability per redemption.
+ */
+const CATALOGUE: {
+ id: string;
+ name: string;
+ costLyt: number;
+ usageRate: number;
+ status: RewardStatus;
+ /** Days from now; null never expires. */
+ expiresInDays: number | null;
+}[] = [
+ {id: 'r-coffee', name: 'Free Coffee', costLyt: 120, usageRate: 0.72, status: 'active', expiresInDays: 34},
+ {id: 'r-combo20', name: 'Combo 20% off', costLyt: 200, usageRate: 0.34, status: 'expiring', expiresInDays: 2},
+ {id: 'r-weekend', name: 'Weekend Bonus', costLyt: 150, usageRate: 0.81, status: 'expiring', expiresInDays: 4},
+ {id: 'r-birthday', name: 'Birthday Treat', costLyt: 250, usageRate: 0.55, status: 'active', expiresInDays: null},
+ {id: 'r-bogo', name: 'Buy 1 Get 1', costLyt: 400, usageRate: 0.19, status: 'active', expiresInDays: 61},
+ {id: 'r-dessert', name: 'Dessert on us', costLyt: 90, usageRate: 0.44, status: 'paused', expiresInDays: 20},
+ {id: 'r-firstvisit', name: 'First Visit 10%', costLyt: 100, usageRate: 0.63, status: 'active', expiresInDays: 90},
+ {id: 'r-loyalty500', name: 'Loyalty 500', costLyt: 500, usageRate: 0.28, status: 'expired', expiresInDays: -6},
+];
+
+export function buildRewards(
+ storeId: string,
+ range: RangeKey,
+ endMs: number,
+): Reward[] {
+ const scoped = storeId === 'all' ? 1 : 0.28;
+
+ return CATALOGUE.map((r) => {
+ const rng = createRng('reward', r.id, storeId, range);
+ const claimed = Math.round(rng.int(140, 900) * scoped);
+ const used = Math.round(claimed * r.usageRate * rng.float(0.92, 1.08));
+
+ return {
+ id: r.id,
+ name: r.name,
+ costLyt: r.costLyt,
+ claimed,
+ used: Math.min(used, claimed),
+ expiresAt:
+ r.expiresInDays === null
+ ? null
+ : new Date(endMs + r.expiresInDays * 86400000).toISOString(),
+ status: r.status,
+ };
+ });
+}
+
+/**
+ * Redemptions over time.
+ *
+ * Derived from the shared timeseries so redemption volume tracks footfall —
+ * an independent generator would produce a redemption curve that contradicts
+ * the store's own traffic, which is immediately obvious to anyone who knows
+ * their business.
+ */
+export function buildRedemptions(
+ storeId: string,
+ range: RangeKey,
+ endMs: number,
+): TimePoint[] {
+ const points = buildTimeseries(range, storeId, endMs);
+ const rng = createRng('redemptions', storeId, range);
+
+ return points.map((p) => {
+ // Roughly a third of purchases involve a redemption.
+ const redeemed = Math.round(p.purchases * rng.float(0.24, 0.38));
+ const issued = Math.round(redeemed * rng.float(1.5, 2.4));
+ return {
+ t: p.t,
+ // Reusing TimePoint's shape: visitors→issued, purchases→redeemed so the
+ // existing chart primitives apply unchanged.
+ visitors: issued,
+ purchases: redeemed,
+ revenue: redeemed * 140,
+ conversion: issued ? Number(((redeemed / issued) * 100).toFixed(1)) : 0,
+ };
+ });
+}
+
+const LYT_ACTIVITY: {
+ kind: ActivityEvent['kind'];
+ title: string;
+ detail: string;
+}[] = [
+ {kind: 'reward_redeemed', title: 'Free Coffee redeemed', detail: '120 LYTs · Indiranagar Flagship'},
+ {kind: 'reward_redeemed', title: 'Weekend Bonus redeemed', detail: '150 LYTs · Koramangala'},
+ {kind: 'reward_expired', title: 'Loyalty 500 expired', detail: 'Claimed 310 times · used 87'},
+ {kind: 'reward_redeemed', title: 'Dessert on us redeemed', detail: '90 LYTs · Anna Nagar'},
+ {kind: 'purchase', title: 'Combo 20% applied at checkout', detail: '₹640 basket · Jubilee Hills'},
+];
+
+export function buildLytActivity(
+ storeId: string,
+ endMs: number,
+): ActivityEvent[] {
+ const rng = createRng('lyt-activity', storeId);
+ // Accumulated, not redrawn per index — same ordering bug as buildActivity.
+ let minutesAgo = 0;
+
+ return Array.from({length: 24}, (_, i) => {
+ const tpl = LYT_ACTIVITY[i % LYT_ACTIVITY.length];
+ if (i > 0) minutesAgo += rng.int(8, 50);
+ return {
+ id: `l${i}`,
+ at: new Date(endMs - minutesAgo * 60000).toISOString(),
+ kind: tpl.kind,
+ title: tpl.title,
+ detail: tpl.detail,
+ storeId,
+ };
+ });
+}
+
+// ===========================================================================
+// Leaderboard — staff, attendance, summary
+// (was features/staff/mocks/staff.mock.ts)
+// ===========================================================================
+
+const FIRST = [
+ 'Anita', 'Vikram', 'Priya', 'Rahul', 'Deepa', 'Suresh', 'Meera', 'Arjun',
+ 'Kavya', 'Nikhil', 'Sneha', 'Rohit', 'Divya', 'Karthik', 'Ananya', 'Manish',
+ 'Lakshmi', 'Sanjay', 'Pooja', 'Ravi', 'Nisha', 'Ajay', 'Shreya', 'Vinod',
+];
+const LAST = [
+ 'R', 'S', 'M', 'K', 'N', 'T', 'P', 'V', 'B', 'G', 'J', 'D',
+];
+const ROLES = [
+ 'Store manager',
+ 'Shift lead',
+ 'Sales associate',
+ 'Sales associate',
+ 'Cashier',
+];
+
+/**
+ * The roster is sized from each store's own staffCount, so the Staff module
+ * and a store's card cannot disagree about how many people work there — the
+ * store card literally says "14 staff" and this must produce 14 rows.
+ */
+/**
+ * No `endMs` parameter, unlike the other builders: a roster is not a time
+ * series. Who works at a store depends on the store and the period length,
+ * not on which day you ask.
+ */
+export function buildStaff(storeId: string, range: RangeKey): StaffMember[] {
+ // A real platform site id matches no fixture store; the sample then shows
+ // the whole sample roster rather than an empty board.
+ const match = STORE_SEED.filter((s) => s.id === storeId);
+ const stores = match.length > 0 ? match : STORE_SEED;
+
+ const out: StaffMember[] = [];
+ let nameIdx = 0;
+
+ for (const store of stores) {
+ for (let i = 0; i < store.staffCount; i++) {
+ const rng = createRng('staff', store.id, i, range);
+ const first = FIRST[nameIdx % FIRST.length];
+ const last = LAST[(nameIdx * 7 + i) % LAST.length];
+ nameIdx += 1;
+
+ // Attendance is weighted, not uniform: a roster where a fifth of the
+ // team is absent every day would be a business in crisis, not a normal
+ // Tuesday. Most people are present.
+ const roll = rng.next();
+ const attendance: Attendance =
+ store.status === 'closed'
+ ? 'absent'
+ : roll < 0.72
+ ? 'present'
+ : roll < 0.85
+ ? 'late'
+ : roll < 0.94
+ ? 'absent'
+ : 'leave';
+
+ // Sales track the store's own traffic, so a flagship associate
+ // outsells a quiet-branch associate — which is what makes the
+ // leaderboard mean anything.
+ const salesCount = Math.round(
+ rng.int(18, 64) * store.scale * (RANGE_DAYS[range] / 30),
+ );
+ const punctualityPct = Number(rng.float(72, 99).toFixed(1));
+
+ out.push({
+ id: `${store.id}-${i}`,
+ name: `${first} ${last}`,
+ avatarUrl: null,
+ storeId: store.id,
+ role: i === 0 ? ROLES[0] : ROLES[1 + (i % (ROLES.length - 1))],
+ attendance,
+ salesCount,
+ rewardsIssued: Math.round(salesCount * rng.float(0.3, 0.7)),
+ // Weighted toward sales, then punctuality — a scorecard that is
+ // mostly attendance rewards showing up over selling.
+ performanceScore: Math.round(
+ Math.min(
+ 100,
+ (salesCount / (store.scale * 2)) * 0.7 + punctualityPct * 0.3,
+ ),
+ ),
+ punctualityPct,
+ });
+ }
+ }
+
+ return out;
+}
+
+export function buildStaffSummary(
+ storeId: string,
+ range: RangeKey,
+): StaffSummary {
+ const staff = buildStaff(storeId, range);
+ const count = (a: Attendance) =>
+ staff.filter((s) => s.attendance === a).length;
+
+ return {
+ present: count('present'),
+ absent: count('absent'),
+ late: count('late'),
+ onLeave: count('leave'),
+ total: staff.length,
+ };
+}
+
+/** Daily attendance split for the stacked chart. */
+export function buildAttendance(
+ storeId: string,
+ range: RangeKey,
+ endMs: number,
+): AttendancePoint[] {
+ const days = RANGE_DAYS[range];
+ const total = buildStaff(storeId, range).length;
+ const rng = createRng('attendance', storeId, range);
+
+ return Array.from({length: days}, (_, i) => {
+ const date = new Date(endMs - (days - 1 - i) * 86400000);
+ const dow = (date.getUTCDay() + 6) % 7;
+ // Weekends run leaner rosters, and Mondays carry more late arrivals.
+ const rostered = Math.round(total * (dow >= 5 ? 0.72 : 0.94));
+ const late = Math.round(rostered * (dow === 0 ? 0.14 : 0.07) * rng.float(0.6, 1.4));
+ const absent = Math.round(rostered * 0.06 * rng.float(0.4, 1.6));
+
+ return {
+ t: date.toISOString().slice(0, 10),
+ present: Math.max(0, rostered - late - absent),
+ late,
+ absent,
+ };
+ });
+}
+
+// ===========================================================================
+// Dashboard charts — empty-shop fallback reports
+// (was features/dashboard/mocks/dashboardMock.ts)
+// ===========================================================================
+
+/**
+ * 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.
+ */
+
+
+/** 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 {
+ // Same shape as the platform's day buckets: a bare local date.
+ label: d.toISOString().slice(0, 10),
+ 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);
+
+
+// ===========================================================================
+// Engagement — empty-shop fallback activities, campaigns, journey
+// (was features/engagement/mocks/engagementMock.ts)
+// ===========================================================================
+
+/**
+ * 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`.
+ */
+
+
+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',
+};
+
+// ===========================================================================
+// Commerce — what the sales resource does not carry
+// ===========================================================================
+//
+// A platform sale records a total, a shop and a customer: no payment method,
+// no discount or refund, and product lines only on the detail view. So these
+// are sample. Each one is applied to the page's REAL revenue where a rupee
+// figure appears, so a sample panel can never contradict the real total
+// sitting above it — only the split is invented, not the size.
+
+/** Share of revenue by payment method, 0–100; sums to 100. */
+export const SAMPLE_PAYMENT_MIX: {method: string; pct: number}[] = [
+ {method: 'UPI', pct: 58},
+ {method: 'Cards', pct: 28},
+ {method: 'Store credit', pct: 10},
+ {method: 'Cash', pct: 4},
+];
+
+/** Discounts and refunds as a share of gross sales. */
+export const SAMPLE_DISCOUNT_RATE = 0.042;
+export const SAMPLE_REFUND_RATE = 0.022;
+
+/**
+ * Example sale list for a shop that has recorded no sales in the window —
+ * the Commerce page's cards, trend charts and outlet ranking all derive from
+ * it, exactly as they would from GET /api/sales.
+ *
+ * Sales are spread across the stores the merchant really has (so Top
+ * performing outlets shows their own shop names), weighted to a lunch and an
+ * evening peak, and never placed after `serverNow` — Today stops at this hour.
+ */
+export function buildSampleSales({
+ siteIds,
+ range,
+ custom,
+ serverNow,
+}: {
+ /** The real store ids in scope; the fixture roster when there are none. */
+ siteIds: string[];
+ range: RangeKey;
+ custom: {from: string; to: string} | null;
+ serverNow: string;
+}): Sale[] {
+ const sites = siteIds.length ? siteIds : STORE_SEED.map((s) => s.id);
+ const now = new Date(serverNow);
+ const today = shopToday(now);
+ const nowHour = Number(
+ new Intl.DateTimeFormat('en-GB', {
+ timeZone: DEFAULT_TZ,
+ hour: '2-digit',
+ hourCycle: 'h23',
+ }).format(now),
+ );
+ const {from, to} = toReportWindow(range, now, undefined, custom);
+ // Opening hours, weighted: a lunch and an evening rush.
+ const HOURS = [10, 11, 12, 12, 13, 13, 14, 15, 16, 17, 18, 18, 19, 19, 20, 21];
+ const CUSTOMERS = ['Priya S.', 'Rahul M.', 'Ananya K.', 'Vikram R.', 'Meera N.', 'Arjun P.', 'Kavya D.', 'Rohan T.'];
+ const STAFF = ['Divya', 'Karthik', 'Sneha', 'Imran', 'Lakshmi'];
+ const out: Sale[] = [];
+
+ for (let day = from; day <= to; day = shiftDay(day, 1)) {
+ sites.forEach((siteId, i) => {
+ const rng = createRng('commerce-sales', siteId, day);
+ // First store busiest, the rest tapering — a ranking worth reading.
+ const weight = Math.max(0.45, 1 - i * 0.18);
+ const count = Math.round(rng.int(6, 14) * weight);
+ for (let n = 0; n < count; n++) {
+ const hour = rng.pick(HOURS);
+ if (day === today && hour > nowHour) continue;
+ const minute = rng.int(0, 59);
+ const purchasedLines = rng.int(1, 4);
+ const repeat = rng.next() < 0.7;
+ const c = rng.int(0, CUSTOMERS.length - 1);
+ out.push({
+ id: `sample-${siteId}-${day}-${n}`,
+ invoiceNo: `INV-${day.replaceAll('-', '').slice(2)}-${String(i * 100 + n).padStart(3, '0')}`,
+ siteId,
+ customerRef: repeat ? `sample-cust-${siteId}-${c}` : null,
+ customerLabel: repeat ? CUSTOMERS[c] : null,
+ staffName: rng.pick(STAFF),
+ // ₹180–₹2,400 per bill, in integer paise.
+ totalPaise: rng.int(180, 2400) * 100,
+ currency: 'INR',
+ status: rng.next() < 0.03 ? 'void' : 'completed',
+ at: `${day}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00+05:30`,
+ purchasedLines,
+ enquiryLines: rng.next() < 0.35 ? rng.int(1, 2) : 0,
+ lines: [],
+ });
+ }
+ });
+ }
+ return out;
+}
+
+export type SampleStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
+
+export interface SampleProduct extends Record {
+ id: string;
+ name: string;
+ category: string;
+ units: number;
+ revenueInr: number;
+ /** Change against the previous equal window, in percent. */
+ deltaPct: number;
+ stock: number;
+ stockStatus: SampleStockStatus;
+}
+
+const SAMPLE_PRODUCTS: {id: string; name: string; category: string; priceInr: number}[] = [
+ {id: 'p1', name: 'Signature Espresso Blend 1kg', category: 'Beans', priceInr: 300},
+ {id: 'p2', name: 'Cold Brew Pack (6)', category: 'Ready to drink', priceInr: 300},
+ {id: 'p3', name: 'Ceramic Mug, Matte Black', category: 'Merchandise', priceInr: 350},
+ {id: 'p4', name: 'Vanilla Syrup 750ml', category: 'Syrups', priceInr: 250},
+ {id: 'p5', name: 'French Press, 8 cup', category: 'Equipment', priceInr: 400},
+];
+
+export function buildTopProducts(storeId: string, range: RangeKey): SampleProduct[] {
+ const rng = createRng('commerce-products', storeId, range);
+ const scale = storeScale(storeId) * RANGE_DAYS[range];
+ return SAMPLE_PRODUCTS.map((p, i): SampleProduct => {
+ const units = Math.max(1, Math.round(rng.int(10, 16) * scale * (1 - i * 0.16)));
+ const stock = i === 4 ? 0 : rng.int(4, 150);
+ return {
+ id: p.id,
+ name: p.name,
+ category: p.category,
+ units,
+ revenueInr: units * p.priceInr,
+ deltaPct: Number(rng.float(-6, 20).toFixed(1)),
+ stock,
+ stockStatus: stock === 0 ? 'out_of_stock' : stock < 20 ? 'low_stock' : 'in_stock',
+ };
+ }).sort((a, b) => b.revenueInr - a.revenueInr);
+}
+
+// ===========================================================================
+// Settings and Loyaly AI — example records until the platform has endpoints
+// ===========================================================================
+//
+// API keys, webhooks, invoices, bank details, business contact, integration
+// credentials, the login audit and the assistant's insight cards. None of it
+// is the merchant's; each screen reads it from here.
+
+export const SAMPLE_API_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',
+ },
+];
+
+export const SAMPLE_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',
+ },
+];
+
+export const SAMPLE_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 const SAMPLE_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: '#',
+ },
+];
+
+export const SAMPLE_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 const SAMPLE_INTEGRATIONS: 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 const SAMPLE_LOGIN_AUDIT: 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 const SAMPLE_AI_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 const SAMPLE_BANK_ACCOUNT = {account: 'HDFC Bank •••• 8842', ifsc: 'HDFC0001234'};
+
+/** A masked key for a newly "generated" API key — there is no key service yet. */
+export function sampleApiKeyPrefix(): string {
+ const part = () => Math.random().toString(36).substring(2, 6);
+ return `lmer_live_${part()}••••••••${part()}`;
+}
+
+/** A masked signing secret for a newly added webhook. */
+export function sampleWebhookSecret(): string {
+ return `whsec_${Math.random().toString(36).substring(2, 8)}••••`;
+}
+
+/** The rows Loyaly AI's "Download CSV/PDF report" actions export. */
+export const SAMPLE_AI_EXPORT_ROWS = [
+ {metric: 'Daily Revenue', val: '₹3,40,000'},
+ {metric: 'Visitors', val: '1,284'},
+ {metric: 'Purchases', val: '276'},
+];
+
+/** The assistant landing's store-pulse strip — no live telemetry feed yet. */
+export const SAMPLE_AI_PULSE = {footfallDeltaPct: 18.4, syncPct: 99.8};
diff --git a/src/shared/mocks/withSample.ts b/src/shared/mocks/withSample.ts
index 85b9c39..23d1184 100644
--- a/src/shared/mocks/withSample.ts
+++ b/src/shared/mocks/withSample.ts
@@ -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 .
+ *
+ * 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//mocks/` folder, then fix
- * the call sites TypeScript points at: `withSample(x, …)` → `x`, and drop each
- * ``.
+ * Delete this folder, then fix the call sites TypeScript points at:
+ * `withSample(x, …)` → `x`, `useSampleResource(…)` → the real hook, and drop
+ * each , and .
*/
+import {useMemo} from 'react';
import type {Resource} from '@/shared/hooks/useResource';
+import type {ReportBucket} from '@/features/dashboard/types/reports';
export type SampleAware = Resource & {isSample: boolean};
@@ -26,3 +34,56 @@ export function withSample(
}
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,
+): 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(
+ build: (nowMs: number) => T,
+ deps: readonly unknown[],
+): Resource {
+ // 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>(() => {
+ 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;
+ // `build` is a fresh closure every render; `key` names what it reads.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key]);
+}
diff --git a/src/shared/providers/WorkspaceProvider.tsx b/src/shared/providers/WorkspaceProvider.tsx
index 1617fed..af2b365 100644
--- a/src/shared/providers/WorkspaceProvider.tsx
+++ b/src/shared/providers/WorkspaceProvider.tsx
@@ -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 = {
+ today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
diff --git a/src/shared/services/apiRoute.ts b/src/shared/services/apiRoute.ts
index eef858f..d68b26e 100644
--- a/src/shared/services/apiRoute.ts
+++ b/src/shared/services/apiRoute.ts
@@ -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
diff --git a/src/shared/services/httpClient.ts b/src/shared/services/httpClient.ts
index 6593346..50bf154 100644
--- a/src/shared/services/httpClient.ts
+++ b/src/shared/services/httpClient.ts
@@ -40,6 +40,8 @@ export interface Endpoint {
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(
scope: Scope,
extra: Record = {},
): Endpoint {
- return {path, params: {range: scope.range, storeId: scope.storeId, ...extra}};
+ const custom: Record =
+ 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): string {
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index d640141..fdf41a7 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -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. */
diff --git a/src/shared/utils/accent.ts b/src/shared/utils/accent.ts
index b169e28..11888a2 100644
--- a/src/shared/utils/accent.ts
+++ b/src/shared/utils/accent.ts
@@ -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'],
},
};
diff --git a/src/shared/utils/icons.ts b/src/shared/utils/icons.ts
index 0128c4d..e27e2d9 100644
--- a/src/shared/utils/icons.ts
+++ b/src/shared/utils/icons.ts
@@ -95,6 +95,7 @@ export const ICONS = {
dashboard: LayoutDashboard,
commerce: ShoppingCart,
stores: Store,
+ cameras: Camera,
lyts: Gift,
staff: Users,
settings: Settings,