update ui changes

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

View File

@@ -0,0 +1,315 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {List, ListItem} from '@astryxdesign/core/List';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {proportional, pixel, type TableColumn} from '@astryxdesign/core/Table';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {StatPair} from '@/shared/components/patterns/StatPair';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {SampleScope} from '@/shared/mocks/SampleScope';
import {useSampleResource} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {
SAMPLE_DISCOUNT_RATE,
SAMPLE_PAYMENT_MIX,
SAMPLE_REFUND_RATE,
buildTopProducts,
storeName as sampleStoreName,
type SampleProduct,
type SampleStockStatus,
} from '@/shared/mocks/sampleData';
import {useScope} from '@/shared/hooks/useScope';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {formatCompact, formatInr, formatInrCompact} from '@/shared/utils/format';
import {
byOutlet,
salesFacts,
totalPaise,
} from '@/features/commerce/services/salesAnalysis';
import {formatPaise} from '@/features/commerce/services/money';
import type {Resource} from '@/shared/hooks/useResource';
import type {Sale} from '@/features/commerce/types/sale';
/**
* The Commerce page's analytics panels, in the merchant app's layout.
*
* REAL: Top performing outlets and Sales insights — both computed from the
* platform's sales, so they move with every sale recorded.
*
* SAMPLE: Payment methods, Top products and the refund/discount lines of the
* Revenue summary. The sales resource carries no payment method, discount,
* refund or (in the list) product line. Each sits in a <SampleScope>, and
* every rupee figure is the sample SPLIT applied to the REAL revenue, so the
* panels can never disagree with the total above them. The values themselves
* live in shared/mocks/sampleData.ts.
*/
const noSales = (
<EmptyPanel
icon="commerce"
title="No sales in this period"
description="This fills in as staff record sales in the merchant app."
/>
);
export function PaymentMethodsPanel({sales}: {sales: Resource<Sale[]>}) {
return (
<SampleScope>
<PanelCard
title="Payment methods"
subtitle="Share of revenue by how customers paid"
resource={sales}
loading={<SkeletonRows count={4} />}
empty={noSales}
>
{(rows) => {
const revenue = totalPaise(rows) / 100;
return (
<VStack gap={4}>
{SAMPLE_PAYMENT_MIX.map((p) => (
<ProgressBar
key={p.method}
label={p.method}
value={p.pct}
variant="neutral"
hasValueLabel
formatValueLabel={(v) =>
`${v}% · ${formatInrCompact((revenue * p.pct) / 100)}`
}
/>
))}
</VStack>
);
}}
</PanelCard>
</SampleScope>
);
}
export function TopOutletsPanel({
sales,
isSample = false,
}: {
sales: Resource<Sale[]>;
isSample?: boolean;
}) {
const {stores} = useWorkspace();
// Sample sales fall back to the fixture roster when the merchant has no
// stores loaded, so those ids resolve through sampleStoreName.
const name = (id: string) =>
stores.find((s) => s.id === id)?.name ?? (isSample ? sampleStoreName(id) : id);
return (
<PanelCard
title="Top performing outlets"
subtitle="Sales by store in the selected period"
actions={<SampleTag show={isSample} />}
resource={sales}
loading={<SkeletonRows count={4} />}
empty={noSales}
>
{(rows) => {
const outlets = byOutlet(rows);
const top = outlets[0]?.paise || 1;
return (
<VStack gap={4}>
{outlets.map((o) => (
<ProgressBar
key={o.siteId}
label={name(o.siteId)}
value={o.paise}
max={top}
variant="neutral"
hasValueLabel
formatValueLabel={() =>
`${formatInrCompact(o.paise / 100)} · ${formatCompact(o.purchases)} orders`
}
/>
))}
</VStack>
);
}}
</PanelCard>
);
}
const STOCK: Record<SampleStockStatus, {label: string; variant: 'success' | 'warning' | 'error'}> = {
in_stock: {label: 'In stock', variant: 'success'},
low_stock: {label: 'Low', variant: 'warning'},
out_of_stock: {label: 'Out', variant: 'error'},
};
const PRODUCT_COLUMNS: TableColumn<SampleProduct>[] = [
{
key: 'name',
header: 'Product',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0.5}>
<Text size="base" weight="semibold">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.category}
</Text>
</VStack>
),
},
{
key: 'units',
header: 'Units',
width: pixel(80),
align: 'end',
renderCell: (row) => <Text size="base">{formatCompact(row.units)}</Text>,
},
{
key: 'revenueInr',
header: 'Revenue',
width: pixel(104),
align: 'end',
renderCell: (row) => (
<Text size="base" weight="medium">
{formatInrCompact(row.revenueInr)}
</Text>
),
},
{
key: 'deltaPct',
header: 'Change',
width: pixel(88),
align: 'end',
renderCell: (row) => <MetricDelta value={row.deltaPct} />,
},
{
key: 'stockStatus',
header: 'Stock',
width: pixel(88),
align: 'end',
renderCell: (row) => (
<Badge variant={STOCK[row.stockStatus].variant} label={STOCK[row.stockStatus].label} />
),
},
];
export function TopProductsPanel() {
const {storeId, range} = useScope();
const products = useSampleResource(() => buildTopProducts(storeId, range), [storeId, range]);
return (
<SampleScope>
<PanelCard
title="Top products"
subtitle="Best sellers by revenue"
resource={products}
loading={<SkeletonRows count={5} />}
>
{(rows) => (
<ResponsiveTable
data={rows}
columns={PRODUCT_COLUMNS}
idKey="id"
primaryKey="name"
density="compact"
/>
)}
</PanelCard>
</SampleScope>
);
}
export function SalesInsightsPanel({sales}: {sales: Resource<Sale[]>}) {
const {stores} = useWorkspace();
const name = (id: string) => stores.find((s) => s.id === id)?.name ?? id;
return (
<PanelCard
title="Sales insights"
subtitle="What stands out this period"
resource={sales}
loading={<SkeletonRows count={3} />}
empty={noSales}
>
{(rows) => {
const facts = salesFacts(rows);
const lead = byOutlet(rows)[0];
const asked = facts.enquiries + facts.purchasedLines;
const items = [
facts.peakHour && {
id: 'peak',
label: `${String(facts.peakHour.hour).padStart(2, '0')}:00 is the busiest hour`,
description: `${facts.peakHour.pct}% of sales in this period landed in that hour.`,
badge: <Badge variant="success" label="Staffing" />,
},
facts.enquiries > 0 && {
id: 'enquiries',
label: `${formatCompact(facts.enquiries)} items asked about but not bought`,
description: `${Math.round((facts.enquiries / asked) * 100)}% of items customers raised did not make it to the bill.`,
badge: <Badge variant="warning" label="Opportunity" />,
},
lead && stores.length > 1 && {
id: 'outlet',
label: `${name(lead.siteId)} leads on sales`,
description: `${formatInr(lead.paise / 100)} from ${formatCompact(lead.purchases)} orders.`,
badge: <Badge variant="success" label="Top store" />,
},
facts.largest && {
id: 'largest',
label: `Largest sale ${formatPaise(facts.largest.totalPaise)}`,
description: [facts.largest.invoiceNo, name(facts.largest.siteId)]
.filter(Boolean)
.join(' · '),
badge: null,
},
].filter(Boolean) as {id: string; label: string; description: string; badge: React.ReactNode}[];
return (
<List density="balanced">
{items.map((i) => (
<ListItem
key={i.id}
label={i.label}
description={i.description}
endContent={i.badge}
/>
))}
</List>
);
}}
</PanelCard>
);
}
export function RevenueSummaryPanel({sales}: {sales: Resource<Sale[]>}) {
return (
<SampleScope>
<PanelCard
title="Revenue summary"
subtitle="Gross to net — gross is real, discounts and refunds are examples"
resource={sales}
loading={<SkeletonRows count={2} />}
empty={noSales}
>
{(rows) => {
const gross = totalPaise(rows) / 100;
const discounts = gross * SAMPLE_DISCOUNT_RATE;
const refunds = gross * SAMPLE_REFUND_RATE;
return (
<Grid columns={2} gap={5}>
<StatPair label="Gross sales" value={formatInr(gross)} />
<StatPair label="Discounts" value={`− ${formatInr(discounts)}`} />
<StatPair label="Refunds" value={`− ${formatInr(refunds)}`} />
<StatPair label="Net revenue" value={formatInr(gross - discounts - refunds)} />
</Grid>
);
}}
</PanelCard>
</SampleScope>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import {useId} from 'react';
import {Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis} from 'recharts';
import {Card} from '@astryxdesign/core/Card';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {ChartFrame, AXIS_TICK} from '@/shared/components/charts/ChartFrame';
import {CHART} from '@/shared/components/charts/palette';
import {useChartMotion} from '@/shared/components/charts/useChartMotion';
import {formatCount, formatInr, formatInrCompact} from '@/shared/utils/format';
import type {TrendPoint} from '@/features/commerce/services/salesAnalysis';
/**
* Daily sales across the selected period — compact, one area in brand warm
* (sales are money, and money is warm on this console).
*
* Its own small chart rather than AreaChartView because the tooltip needs two
* units at once — rupees for sales, a plain count for purchases — and the
* shared tooltip formats every series with the one `yFormat`.
*/
export function SalesTrendChart({
data,
height = 180,
}: {
data: TrendPoint[];
height?: number;
}) {
const uid = useId().replace(/:/g, '');
const motion = useChartMotion(true);
return (
<ChartFrame height={height}>
<AreaChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
<defs>
<linearGradient id={`sales-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.brand.warm} stopOpacity={0.35} />
<stop offset="100%" stopColor={CHART.brand.warm} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={CHART.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="label"
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
minTickGap={24}
/>
<YAxis
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
width={48}
tickCount={4}
tickFormatter={(v: number) => formatInrCompact(v)}
/>
<Tooltip
cursor={{stroke: CHART.axisLine}}
content={({active, payload}) => {
const p = active ? (payload?.[0]?.payload as TrendPoint | undefined) : undefined;
if (!p) return null;
return (
<Card elevation="high" padding={3}>
<VStack gap={1}>
<Text size="sm" color="secondary">
{p.label}
</Text>
<Text size="sm" weight="semibold">
Sales {formatInr(p.sales)}
</Text>
<Text size="sm">Purchases {formatCount(p.purchases)}</Text>
</VStack>
</Card>
);
}}
/>
<Area
type="monotone"
dataKey="sales"
name="Sales"
stroke={CHART.brand.warm}
strokeWidth={2.5}
fill={`url(#sales-${uid})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 4,
stroke: CHART.brand.warm,
strokeWidth: 2,
fill: 'var(--color-background-surface)',
}}
{...motion}
/>
</AreaChart>
</ChartFrame>
);
}

View File

@@ -9,3 +9,16 @@ import type {Sale} from '@/features/commerce/types/sale';
export function useSales(): Resource<Sale[]> {
return useResource(saleRepository.list(useScope()));
}
/**
* Today's sales for the selected store, whatever range is picked — the Sales
* card's "Today" line. Its own request because a custom range may not include
* today at all. `enabled: false` sends nothing (the page already has today's
* rows when the range IS today).
*/
export function useTodaySales(enabled = true): Resource<Sale[]> {
const {storeId} = useScope();
return useResource(
enabled ? saleRepository.list({storeId, range: 'today'}) : null,
);
}

View File

@@ -1,77 +0,0 @@
/**
* TEMPORARY sample data for the Sales page.
*
* - `MOCK_SALES` stands in for the sales list only while the platform returns
* none (see `shared/mocks/withSample.ts`). Sample rows never open the detail
* dialog — there is no sale behind them to fetch.
* - `MOCK_TOP_PRODUCTS` and `MOCK_PAYMENT_METHODS` have NO platform resource
* yet. They are always sample and always tagged. When the catalogue and
* payment endpoints ship, replace them with hooks and delete this file.
*
* Money is integer paise, like the real contract.
*/
import type {Sale} from '@/features/commerce/types/sale';
function sale(
n: number,
customer: string | null,
staff: string,
totalPaise: number,
purchased: number,
enquired: number,
at: string,
): Sale {
return {
id: `sample-sale-${n}`,
invoiceNo: `INV-${String(2400 + n).padStart(5, '0')}`,
siteId: 'sample',
customerRef: null,
customerLabel: customer,
staffName: staff,
totalPaise,
currency: 'INR',
status: 'completed',
at,
purchasedLines: purchased,
enquiryLines: enquired,
lines: [],
};
}
export const MOCK_SALES: Sale[] = [
sale(18, 'Priya S.', 'Arjun', 248_900, 3, 1, '2026-09-25T11:42:00Z'),
sale(17, 'Rahul M.', 'Divya', 89_900, 1, 0, '2026-09-25T10:15:00Z'),
sale(16, null, 'Arjun', 159_800, 2, 2, '2026-09-24T18:03:00Z'),
sale(15, 'Kavya R.', 'Meena', 412_500, 4, 0, '2026-09-24T16:27:00Z'),
sale(14, 'Sanjay K.', 'Divya', 64_900, 1, 1, '2026-09-24T13:50:00Z'),
sale(13, 'Anitha P.', 'Meena', 198_000, 2, 0, '2026-09-23T19:11:00Z'),
];
export interface SampleProduct {
id: string;
name: string;
sold: number;
revenuePaise: number;
}
export const MOCK_TOP_PRODUCTS: SampleProduct[] = [
{id: 'p1', name: 'Cotton kurta — indigo', sold: 42, revenuePaise: 5_451_600},
{id: 'p2', name: 'Silk dupatta', sold: 31, revenuePaise: 3_689_000},
{id: 'p3', name: 'Linen shirt — white', sold: 27, revenuePaise: 2_427_300},
{id: 'p4', name: 'Handloom saree', sold: 12, revenuePaise: 5_388_000},
{id: 'p5', name: 'Leather sandals', sold: 19, revenuePaise: 1_519_000},
];
export interface SamplePaymentMethod {
method: string;
/** Share of sales by count, as a percentage. */
share: number;
}
export const MOCK_PAYMENT_METHODS: SamplePaymentMethod[] = [
{method: 'UPI', share: 58},
{method: 'Card', share: 24},
{method: 'Cash', share: 14},
{method: 'Wallet', share: 4},
];

View File

@@ -0,0 +1,240 @@
/**
* The Sales screen's numbers, derived from ONE source: the sale list for the
* selected store and window (GET /api/sales). The cards, the trend chart and
* the recent-sales list all read the same rows, so they cannot disagree.
*
* Money stays integer PAISE throughout — see types/sale.ts.
*
* ── Customers ────────────────────────────────────────────────────────────
* Counted by `customerRef`, the platform's permanent visitor reference, so a
* regular who bought three times is one customer. A sale with no identified
* customer adds to Sales and Purchases but not to Customers — it cannot be
* told apart from any other anonymous sale.
*
* ── Days ─────────────────────────────────────────────────────────────────
* A sale's `at` is an instant. It is placed on a calendar day (and hour) in
* the shop's timezone (DEFAULT_TZ), never the viewer's — a sale at 23:30 IST
* is that day's trade wherever the owner is. The window's days come from the
* same `toReportWindow` the server filtered with, so the chart's x-axis and
* the rows can never cover different days.
*/
import {DEFAULT_TZ, shiftDay, toReportWindow} from '@/services/api/range';
import type {RangeKey} from '@/shared/types/api';
import type {Sale} from '@/features/commerce/types/sale';
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: DEFAULT_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const HOUR_FMT = new Intl.DateTimeFormat('en-GB', {
timeZone: DEFAULT_TZ,
hour: '2-digit',
hourCycle: 'h23',
});
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** "2026-09-27" — the sale's calendar day in the shop's zone. */
export function shopDay(iso: string): string {
return DAY_FMT.format(new Date(iso));
}
function shopHour(iso: string): number {
return Number(HOUR_FMT.format(new Date(iso)));
}
/** "Sep 25" from "2026-09-25", by string — never through a Date and a zone. */
export function dayLabel(day: string): string {
const [, m, d] = day.split('-');
return `${MONTHS[Number(m) - 1]} ${d}`;
}
/** Only completed trade counts toward a total — a voided sale never happened. */
function counts(s: Sale): boolean {
return s.status !== 'void';
}
export interface Window {
range: RangeKey;
custom: {from: string; to: string} | null;
/** The server's clock (response `meta.generatedAt`), never the device's. */
serverNow: string;
}
/** Every calendar day of the window, oldest first. */
function windowDays({range, custom, serverNow}: Window): string[] {
const {from, to} = toReportWindow(range, new Date(serverNow), undefined, custom);
const out: string[] = [];
for (let d = from; d <= to; d = shiftDay(d, 1)) out.push(d);
return out;
}
export interface SalesSummary {
salesPaise: number;
purchases: number;
/** Null when there were no purchases — never a division by zero. */
averagePaise: number | null;
customers: number;
/** Per-day series across the window, for each card's sparkline. */
trend: {
sales: {v: number}[];
purchases: {v: number}[];
/** Only days that had a sale: a day with none has no average. */
average: {v: number}[];
customers: {v: number}[];
};
}
export function summarise(sales: Sale[], window: Window): SalesSummary {
const days = new Map(
windowDays(window).map((d) => [
d,
{paise: 0, purchases: 0, customers: new Set<string>()},
]),
);
const customers = new Set<string>();
let salesPaise = 0;
let purchases = 0;
for (const s of sales) {
if (!counts(s)) continue;
salesPaise += s.totalPaise;
purchases += 1;
if (s.customerRef) customers.add(s.customerRef);
const day = days.get(shopDay(s.at));
if (day) {
day.paise += s.totalPaise;
day.purchases += 1;
if (s.customerRef) day.customers.add(s.customerRef);
}
}
const perDay = [...days.values()];
return {
salesPaise,
purchases,
averagePaise: purchases > 0 ? Math.round(salesPaise / purchases) : null,
customers: customers.size,
trend: {
sales: perDay.map((d) => ({v: d.paise})),
purchases: perDay.map((d) => ({v: d.purchases})),
average: perDay
.filter((d) => d.purchases > 0)
.map((d) => ({v: Math.round(d.paise / d.purchases)})),
customers: perDay.map((d) => ({v: d.customers.size})),
},
};
}
/** Total of completed sales — the Sales card's "Today" line. */
export function totalPaise(sales: Sale[]): number {
return sales.reduce((sum, s) => (counts(s) ? sum + s.totalPaise : sum), 0);
}
export interface TrendPoint {
/** Axis label: "Sep 25", or "14:00" when the window is today. */
label: string;
/** Rupees, for the axis and chart. Totals stay paise until this point. */
sales: number;
purchases: number;
}
/**
* One point per day of the window — or, for Today, one per hour up to now,
* since a single-day window as a single point is not a trend. Days with no
* sales are zero points, so a quiet day reads as a dip rather than vanishing.
*/
export function salesTrend(sales: Sale[], window: Window): TrendPoint[] {
if (window.range === 'today') {
const nowHour = shopHour(window.serverNow);
const hours = Array.from({length: nowHour + 1}, (_, h) => ({
label: `${String(h).padStart(2, '0')}:00`,
paise: 0,
purchases: 0,
}));
for (const s of sales) {
if (!counts(s)) continue;
const h = hours[shopHour(s.at)];
if (!h) continue;
h.paise += s.totalPaise;
h.purchases += 1;
}
return hours.map((h) => ({label: h.label, sales: h.paise / 100, purchases: h.purchases}));
}
const days = new Map(
windowDays(window).map((d) => [d, {paise: 0, purchases: 0}]),
);
for (const s of sales) {
if (!counts(s)) continue;
const d = days.get(shopDay(s.at));
if (!d) continue;
d.paise += s.totalPaise;
d.purchases += 1;
}
return [...days.entries()].map(([day, d]) => ({
label: dayLabel(day),
sales: d.paise / 100,
purchases: d.purchases,
}));
}
export interface OutletTotal {
siteId: string;
paise: number;
purchases: number;
}
/** Sales per shop, highest first — the real "Top performing outlets". */
export function byOutlet(sales: Sale[]): OutletTotal[] {
const out = new Map<string, OutletTotal>();
for (const s of sales) {
if (!counts(s)) continue;
const o = out.get(s.siteId) ?? {siteId: s.siteId, paise: 0, purchases: 0};
o.paise += s.totalPaise;
o.purchases += 1;
out.set(s.siteId, o);
}
return [...out.values()].sort((a, b) => b.paise - a.paise);
}
export interface SalesFacts {
/** Busiest hour of day (0–23, shop time) and its share of sales, 0–100. */
peakHour: {hour: number; pct: number} | null;
/** Lines customers asked about but did not buy, and lines they bought. */
enquiries: number;
purchasedLines: number;
/** The largest single sale in the window. */
largest: Sale | null;
}
/**
* The raw material for "Sales insights" — every sentence on that panel is
* computed from here, so none of it is copy that could go stale.
*/
export function salesFacts(sales: Sale[]): SalesFacts {
const hours = new Array<number>(24).fill(0);
let total = 0;
let enquiries = 0;
let purchasedLines = 0;
let largest: Sale | null = null;
for (const s of sales) {
if (!counts(s)) continue;
hours[shopHour(s.at)] += s.totalPaise;
total += s.totalPaise;
enquiries += s.enquiryLines;
purchasedLines += s.purchasedLines;
if (!largest || s.totalPaise > largest.totalPaise) largest = s;
}
const peak = hours.reduce((best, v, h) => (v > hours[best] ? h : best), 0);
return {
peakHour: total > 0 ? {hour: peak, pct: Math.round((hours[peak] / total) * 100)} : null,
enquiries,
purchasedLines,
largest,
};
}

View File

@@ -0,0 +1,146 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {ACCENT} from '@/shared/utils/accent';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
/**
* One activity, at a weight the dashboard can afford.
*
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
* rather than a white card on the page, the number is `display-3` against the
* KPI row's own display-3 but in a card a third the height, and there is no
* sparkline. Six activities rendered at KPI weight would out-shout the four
* metrics that actually describe the business — the same reasoning that made
* store comparison small multiples instead of five full charts.
*
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
* "N customers → N purchases", because an activity count on its own is a
* vanity metric: 724 spins is not a fact a merchant can do anything with until
* they know what it was worth. `detail` opens the chain up to its full length
* for the analytics page, where there is room for the middle of the story.
*/
/**
* Status is a DOT, not a badge.
*
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
* thing on a page whose subject is the numbers beside them, and a third hue
* competing with the two the brand actually owns. An 8px dot carries the same
* three states at a fraction of the ink, which is the project's own rule:
* status → StatusDot, badge → counts and enumerated states.
*/
const STATUS: Record<
ActivityMetric['status'],
{label: string; variant: 'success' | 'warning' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
paused: {label: 'Paused', variant: 'warning'},
draft: {label: 'Draft', variant: 'neutral'},
};
export function ActivityCard({
metric,
variant = 'summary',
}: {
metric: ActivityMetric;
/**
* `summary` — the dashboard's six. Count, delta, and the two ends of the
* impact chain. No status: a summary of six live activities does not need
* six "Live" badges competing with the numbers.
*
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
* rather than reads: whether it is running, what it costs in LYTs, and the
* full chain including reward claims and repeat visits.
*/
variant?: 'summary' | 'catalogue';
}) {
const accent = ACCENT[metric.accent];
const isCatalogue = variant === 'catalogue';
const chain = impactChain(metric, {compact: !isCatalogue});
const status = STATUS[metric.status];
// `card-nested` keeps the dark theme's raised fill and gives the light theme
// a white surface with a hairline edge instead — see globals.css.
return (
<Card variant="muted" className="card-nested">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center" className="min-w-0">
{/*
The only place the brand hue appears on this card. A tinted chip
at 16px is enough to identify the activity at a glance; tinting
the card itself is how a grid of six turns into a paint chart.
*/}
<HStack
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
vAlign="center"
>
<Icon
icon={ACTIVITY_ICON[metric.id]}
size="sm"
className={accent.ink}
/>
</HStack>
<Text size="sm" weight="medium" className="truncate">
{metric.label}
</Text>
</HStack>
{isCatalogue ? (
<HStack gap={1.5} vAlign="center" className="shrink-0">
<StatusDot variant={status.variant} label={status.label} />
<Text size="xsm" color="secondary">
{status.label}
</Text>
</HStack>
) : (
<MetricDelta value={metric.deltaPct} size="xsm" />
)}
</HStack>
{/* Data, not a section title — Text rather than Heading, so a grid of
activity counts stays out of the document outline. */}
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
<Text type="display-3">{formatCompact(metric.count)}</Text>
{/* The catalogue traded its delta for the status badge above, so the
trend comes back down here — a merchant deciding whether to keep
an activity running needs both. */}
{isCatalogue ? (
<MetricDelta value={metric.deltaPct} size="xsm" />
) : null}
</HStack>
{isCatalogue ? (
<Text size="xsm" color="secondary">
{metric.description}
</Text>
) : null}
<Text size="xsm" color="secondary">
{chain
.map((s) => `${formatCount(s.value)} ${s.label}`)
.join(' → ')}
</Text>
{/* The LYT link, and the one number on this card that is MEASURED
rather than attributed — the ledger knows what it issued. Omitted
where the activity grants nothing, rather than shown as zero. */}
{isCatalogue && metric.lytsIssued > 0 ? (
<HStack gap={1} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
<Text size="xsm" color="secondary">
{formatLyt(metric.lytsIssued)} issued
</Text>
</HStack>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The complete ecosystem, grouped by the question each group answers.
*
* Ten activities laid out as one flat grid is a list of features. Split three
* ways it becomes a diagnosis: engagement says what customers are doing,
* growth says what is bringing people in, commerce says whether either turned
* into business. A merchant with a flat conversion number and a rising
* engagement number knows which of the three to look at.
*
* One resource, three panels. The panels share a single fetch, so the groups
* can never disagree about a total, and a group with nothing in it renders its
* own empty state rather than leaving a headed card with a void under it.
*/
export function ActivityGroups({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<VStack gap={5}>
{ACTIVITY_GROUPS.map((group) => (
<PanelCard
key={group.id}
title={group.label}
subtitle={group.question}
actions={<AttributionNote basis={basis} />}
resource={resource}
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
empty={
<EmptyPanel
icon="activityVisit"
title={`No ${group.label.toLowerCase()} activity`}
description="Activities appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.group === group.id)
.map((m) => (
// `catalogue` — this is the management view, so each card
// adds what the dashboard summary has no room for: status,
// LYTs issued, and the full impact chain.
<ActivityCard key={m.id} metric={m} variant="catalogue" />
))}
</Grid>
)}
</PanelCard>
))}
</VStack>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
import {
ACTIVITY_ICON,
conversionPct,
} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Every activity's chain, side by side.
*
* The cards above answer "how is Spin doing?". This answers the question they
* cannot: "which activity is worth running?" — and that is a comparison across
* rows, which is a table. Ten small charts would say the same thing in ten
* times the space and still not let anyone rank the last column.
*
* Rows are ordered by the END of the chain, not the start. Sorting by
* interactions puts Walk on top, which is the activity that converts worst;
* sorting by attributed revenue puts the table in the order a merchant would
* spend their next hour in.
*/
/** A flattened row. The table generic needs an index signature; the domain
* type deliberately does not have one. */
interface ImpactRow extends Record<string, unknown> {
id: string;
label: string;
accent: ActivityMetric['accent'];
activityId: ActivityMetric['id'];
count: number;
purchases: number;
conversion: number;
revenueInr: number;
}
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
return metrics
.map((m) => ({
id: m.id,
label: m.label,
accent: m.accent,
activityId: m.id,
count: m.count,
purchases: m.impact.purchases,
conversion: conversionPct(m),
revenueInr: m.impact.attributedRevenueInr,
}))
.sort((a, b) => b.revenueInr - a.revenueInr);
}
const num = (v: number) => (
<Text size="sm" className="tabular-nums">
{formatCount(v)}
</Text>
);
const COLUMNS: TableColumn<ImpactRow>[] = [
{
key: 'label',
header: 'Activity',
width: proportional(1),
// Label only. The one-line description belongs on the cards above, where
// there is width for it; in a cell 120px wide it wrapped to four lines and
// tripled the height of every row in a table whose whole job is to be
// scanned down a column.
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Icon
icon={ACTIVITY_ICON[row.activityId]}
size="sm"
className={ACCENT[row.accent].ink}
/>
<Text size="sm" weight="medium">
{row.label}
</Text>
</HStack>
),
},
{
key: 'count',
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
// so a header longer than its column is silently truncated to "Interacti…"
// at EVERY width, because these columns are fixed px. The subtitle already
// says what is being counted; the header only has to say which column it
// is, and a short one lets the whole set fit the 525px the panel gives it
// with Loyaly AI open.
header: 'Count',
align: 'end',
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
// is ~625px — enough width that the LAST column, the attributed revenue
// this table is sorted by, fell off the edge behind an internal scrollbar.
// The trend is already on every card above; the ranking is only here.
width: pixel(80),
renderCell: (row) => num(row.count),
},
/*
* FIVE columns, and the number is measured rather than chosen. Inside a
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
* each side, plus the 24/32px cell inset contract from globals.css. Six
* columns needed 580 and pushed `Attributed` — the column the table is
* SORTED by — behind an internal scrollbar, which makes the ranking
* invisible at exactly the width most merchants use.
*
* So the two intermediate chain steps are dropped here: customers and
* repeat visits are on every card above and in the phone fallback. What
* survives is what ranking needs — how much happened, what it produced,
* how efficiently, and what it was worth.
*/
{
key: 'purchases',
header: 'Purchases',
align: 'end',
width: pixel(105),
renderCell: (row) => num(row.purchases),
},
{
key: 'conversion',
header: 'Converted',
align: 'end',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium" className="tabular-nums">
{formatPct(row.conversion, 0)}
</Text>
),
},
{
key: 'revenueInr',
// "Revenue" would be a claim this data cannot support. The column ranks
// the table, so it is the one header that most needs to be honest.
header: 'Attributed',
align: 'end',
width: pixel(112),
renderCell: (row) => (
<Text size="sm" weight="semibold" className="tabular-nums">
{formatInrCompact(row.revenueInr)}
</Text>
),
},
];
export function ActivityImpactTable({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Activity impact"
subtitle="Interactions through to estimated attributed revenue, highest first"
resource={resource}
loading={<SkeletonRows count={6} height={44} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="analytics"
title="Nothing to compare yet"
description="Once two or more activities have run, their conversion can be ranked here."
/>
}
>
{(metrics) => (
<ResponsiveTable
data={toRows(metrics)}
columns={COLUMNS}
idKey="id"
primaryKey="label"
density="balanced"
// Conversion earns its place on the phone card: it is the one field
// that ranks activities against each other, which is the whole
// reason this table exists. Repeat visits is the one dropped.
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
/>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ActivityFeed} from '@/shared/components/patterns/ActivityItem';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import type {ActivityEntry, ActivityTone} from '@/shared/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Each event kind gets its own glyph, so the feed can be skimmed by category
* without reading a word of it.
*
* Only one kind carries semantic colour. An expiry is the single event here a
* merchant may need to act on; redemptions, purchases, check-ins and openings
* are the shop working normally, and colouring them would make the one thing
* that matters harder to find.
*/
const KIND: Record<
ActivityKind,
{icon: IconKey; tone: ActivityTone; toneLabel: string}
> = {
purchase: {icon: 'purchases', tone: 'neutral', toneLabel: 'Purchase'},
reward_redeemed: {icon: 'lyts', tone: 'neutral', toneLabel: 'Reward'},
staff_checked_in: {icon: 'staff', tone: 'neutral', toneLabel: 'Staff'},
store_opened: {icon: 'stores', tone: 'neutral', toneLabel: 'Store'},
reward_expired: {icon: 'alert', tone: 'warning', toneLabel: 'Alert'},
};
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
return events.map((e) => ({
id: e.id,
title: e.title,
detail: e.detail,
at: e.at,
...KIND[e.kind],
}));
}
/**
* The feed panel, in two sizes.
*
* Bounded by default: `limit` caps the rows and `height` caps the box, because
* an uncapped feed is the one panel on a dashboard that grows without limit as
* the business gets busier. It reached 784px — taller than any chart on the
* page — which is how an operational log ends up outweighing the insights it
* was meant to support.
*
* Pass `viewAllHref` and the header offers the full history instead. Pass
* neither and it renders everything, which is what the Activity page wants.
*/
export function ActivityTimeline({
resource,
title = 'Recent activity',
subtitle = 'Live feed across the selected store and period',
limit,
height,
viewAllHref,
}: {
resource: Resource<ActivityEvent[]>;
title?: string;
subtitle?: string;
/** Rows to show. Omit for the complete history. */
limit?: number;
/** Fixed content height in px. The list scrolls inside it rather than pushing
* the card taller, so the panel's footprint is the same on every load. */
height?: number;
viewAllHref?: string;
}) {
return (
<PanelCard
title={title}
subtitle={subtitle}
resource={resource}
loading={<SkeletonRows count={limit ?? 6} />}
actions={
viewAllHref ? (
<Button
variant="ghost"
size="sm"
label="View all"
href={viewAllHref}
/>
) : undefined
}
empty={
<EmptyPanel
icon="alert"
title="No activity yet"
description="Events appear here as customers redeem rewards and staff check in."
/>
}
>
{(events) =>
height ? (
<VStack height={height} isScrollable>
<ActivityFeed entries={toEntries(events)} limit={limit} />
</VStack>
) : (
<ActivityFeed entries={toEntries(events)} limit={limit} />
)
}
</PanelCard>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Icon} from '@astryxdesign/core/Icon';
import {Tooltip} from '@astryxdesign/core/Tooltip';
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
/**
* The disclosure that has to sit beside any attributed figure.
*
* Every downstream number in the activity chain — repeat visits, purchases,
* revenue — is MODELLED from observed footfall and purchase patterns. None of
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
* to "Selfie" without saying so invites a merchant to read it as "selfies
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
* product when the till disagrees.
*
* So the panels that show attributed figures carry this mark, and the copy
* around them says "attributed", never "generated" or "earned".
*
* It renders NOTHING when the basis is `'observed'`. That is the point of the
* flag: when a backend arrives that can join purchases to activity events, the
* disclosure retires itself with no copy edit and no component removal.
*
* A quiet secondary glyph, not a warning — this is a footnote about method,
* and an amber icon would rank it above the data it annotates.
*/
export function AttributionNote({basis}: {basis: AttributionBasis}) {
if (basis === 'observed') return null;
return (
<Tooltip content={ATTRIBUTION_NOTE}>
{/*
Focusable, so the note is reachable by keyboard and not only by hover —
it is the only place the estimation is explained, which makes it
content rather than decoration.
*/}
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
</HStack>
</Tooltip>
);
}

View File

@@ -1,16 +1,9 @@
'use client';
import {
Bar,
BarChart,
Cell,
LabelList,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {CHART} from '@/shared/components/charts/palette';
import {AXIS_TICK} from '@/shared/components/charts/ChartFrame';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {ACCENT, type BrandAccent} from '@/shared/utils/accent';
import {formatCompact} from '@/shared/utils/format';
import type {
ConversionReport,
@@ -20,17 +13,17 @@ import type {
interface FlowStep {
name: string;
value: number;
caption: string;
color: string;
/** Share of everyone who came in, 0–100. Null for the base step itself. */
pct: number | null;
accent: BrandAccent;
}
/**
* Visitors → Returning → Purchased, as step bars against one shared scale.
* Visitors → Returning → Purchased, each against one shared base.
*
* Each bar is a share of everyone who came in, so the drop between steps reads
* at a glance. Every figure comes straight off the reports. "Purchased" is
* purchases clamped to visitors: purchases count transactions, and a regular
* who bought twice would otherwise out-run the visitor bar.
* Every figure comes straight off the reports. "Purchased" is purchases
* clamped to visitors: purchases count transactions, and a regular who bought
* twice would otherwise out-run the visitor count.
*/
export function buildFlow(
footfall: FootfallReport,
@@ -41,26 +34,19 @@ export function buildFlow(
footfall.newVisitors ?? footfall.buckets.reduce((a, b) => a + b.newVisitors, 0);
const returning = footfall.returningVisitors ?? Math.max(0, total - fresh);
const purchased = Math.min(conversion.purchases, total);
const share = (v: number) =>
total > 0 ? ` · ${Math.round((v / total) * 100)}%` : '';
const share = (v: number) => (total > 0 ? Math.round((v / total) * 100) : 0);
return [
{name: 'Visitors', value: total, caption: formatCompact(total), color: CHART.brand.cool},
{
name: 'Returning',
value: returning,
caption: formatCompact(returning) + share(returning),
color: CHART.brand.cool,
},
{
name: 'Purchased',
value: purchased,
caption: formatCompact(purchased) + share(purchased),
color: CHART.brand.warm,
},
{name: 'Visitors', value: total, pct: null, accent: 'cool'},
{name: 'Returning', value: returning, pct: share(returning), accent: 'cool'},
{name: 'Purchased', value: purchased, pct: share(purchased), accent: 'warm'},
];
}
/**
* Three stat tiles: the count, its share of visitors, and a thin bar at that
* share. Visitors is the base, so its bar is always full.
*/
export function CustomerFlowChart({
footfall,
conversion,
@@ -71,43 +57,30 @@ export function CustomerFlowChart({
height?: number;
}) {
const steps = buildFlow(footfall, conversion);
const max = Math.max(1, footfall.total);
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart
data={steps}
layout="vertical"
margin={{top: 8, right: 72, bottom: 8, left: 0}}
barCategoryGap="28%"
>
<XAxis type="number" hide domain={[0, max]} />
<YAxis
type="category"
dataKey="name"
width={84}
tick={{...AXIS_TICK, fontSize: 12, fill: 'var(--color-text-secondary)'}}
tickLine={false}
axisLine={false}
/>
<Bar
dataKey="value"
radius={6}
background={{fill: 'var(--color-overlay-hover)', radius: 6}}
isAnimationActive={false}
>
{steps.map((s) => (
<Cell key={s.name} fill={s.color} />
))}
<LabelList
dataKey="caption"
position="right"
offset={10}
fontSize={12}
fill="var(--color-text-primary)"
/>
</Bar>
</BarChart>
</ResponsiveContainer>
<VStack height={height} vAlign="center">
<Grid columns={3} gap={6}>
{steps.map((s) => (
<VStack key={s.name} gap={2}>
<Text size="sm" weight="medium" color="secondary">
{s.name}
</Text>
<Text size="2xl" weight="bold">
{formatCompact(s.value)}
</Text>
<Text size="xsm" color="secondary">
{s.pct === null ? 'Unique people' : `${s.pct}% of visitors`}
</Text>
<HStack width="100%" className="h-1.5 overflow-hidden rounded-full bg-gray-subtle">
<HStack
width={`${s.pct ?? 100}%`}
className={`rounded-full ${ACCENT[s.accent].solid}`}
/>
</HStack>
</VStack>
))}
</Grid>
</VStack>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {useDashboardPerformance} from '@/features/dashboard/hooks/useDashboard';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {formatInrCompact} from '@/shared/utils/format';
/**
* Weekly / monthly rollup.
*
* The granularity is part of the request rather than a client-side regroup:
* the buckets have to come from the same generator the daily series does, or
* the weekly totals quietly stop matching the daily chart above them.
*/
export function PerformancePanel({
granularity,
onGranularityChange,
}: {
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
}) {
// Scope comes from the workspace via the hook rather than from a prop: the
// panel is always showing the same store and period as the page around it,
// and threading that through as an argument only created a way for them to
// disagree.
const resource = useDashboardPerformance(granularity);
return (
<ChartCard
title="Performance"
subtitle={
granularity === 'weekly'
? 'Revenue by ISO week'
: 'Revenue by calendar month'
}
resource={resource}
actions={
<SegmentedControl
value={granularity}
onChange={(v) => onGranularityChange(v as Granularity)}
label="Performance granularity"
size="sm"
>
<SegmentedControlItem value="weekly" label="Weekly" />
<SegmentedControlItem value="monthly" label="Monthly" />
</SegmentedControl>
}
>
{(rows) => (
<BarChartView
data={rows}
xKey="label"
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {formatCompact} from '@/shared/utils/format';
import type {RewardUsagePoint} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Claimed vs used, per reward.
*
* The gap between the two bars is the whole point: a reward claimed 700 times
* and redeemed 130 is an outstanding liability and a broken offer, not a
* success. Plotting the usage *rate* alone would hide the volume; plotting
* volume alone would hide the failure. So both bars, side by side.
*/
export function RewardUsageChart({
resource,
}: {
resource: Resource<RewardUsagePoint[]>;
}) {
return (
<ChartCard
title="Reward usage"
subtitle="Claimed vs actually redeemed — the gap is unspent liability"
resource={resource}
>
{(rows) => (
<BarChartView
data={rows}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Sparkline} from '@/shared/components/charts/Sparkline';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
import type {StoreComparison as StoreComparisonRow} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Small multiples, not a five-series chart.
*
* This is the palette constraint doing its job: five stores on one axis would
* need five colours, and the monochrome ramp only separates about three. One
* card per store is also simply better for the question being asked — "which
* store is the outlier?" is a comparison of shapes, not of overlapping lines.
*/
export function StoreComparisonPanel({
resource,
}: {
resource: Resource<StoreComparisonRow[]>;
}) {
return (
<ChartCard
title="Store comparison"
subtitle="One card per store — five series on one axis would need five colours"
resource={resource}
height={220}
>
{(rows) => {
const best = rows.reduce((a, b) => (b.visitors > a.visitors ? b : a));
const worst = rows.reduce((a, b) => (b.visitors < a.visitors ? b : a));
return (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{rows.map((r) => (
<Card key={r.storeId} variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<Text size="sm" weight="medium">
{r.name}
</Text>
{r.storeId === best.storeId ? (
<Badge variant="success" label="Top" />
) : r.storeId === worst.storeId ? (
<Badge variant="warning" label="Lowest" />
) : null}
</HStack>
{/* A visitor count is data, not a section title. */}
<Text type="display-3">{formatCompact(r.visitors)}</Text>
<Sparkline data={r.trend} dataKey="v" height={34} />
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(r.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(r.conversionPct)}
align="end"
/>
</StatRow>
</VStack>
</Card>
))}
</Grid>
);
}}
</ChartCard>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {IconColor} from '@astryxdesign/core/Icon';
import type {Resource} from '@/shared/hooks/useResource';
/**
* What needs your attention — findings, not a chat box.
*
* The thing that makes an AI panel get ignored is a paragraph of narration
* with nothing at the end of it. So the contract here is strict: every row
* states a number the merchant can check against the panels above, says what
* it means, and ends in ONE action. An insight with no action does not belong
* on this panel; it belongs in whichever chart already shows it.
*
* Severity earns the only colour on the row. A warning is amber because a
* warning is one of the three semantic states; everything else is a cool-tinted
* bulb, which is this product's mark for "the system worked this out" and is
* the same hue the analytics carry.
*/
const SEVERITY: Record<
InsightSeverity,
{icon: IconKey; color?: IconColor; className?: string}
> = {
error: {icon: 'alert', color: 'error'},
warning: {icon: 'alert', color: 'warning'},
success: {icon: 'leaderboard', color: 'success'},
info: {icon: 'insight', className: ACCENT.cool.ink},
};
function InsightRow({insight}: {insight: Insight}) {
const tone = SEVERITY[insight.severity];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[tone.icon]}
size="sm"
color={tone.color}
className={tone.className}
label={insight.severity}
/>
</HStack>
<VStack gap={1} width="100%">
<Text size="sm" weight="medium">
{insight.title}
</Text>
<Text size="sm" color="secondary">
{insight.body}
</Text>
{insight.action ? (
<HStack>
<Button
variant="secondary"
size="sm"
label={insight.action.label}
href={insight.action.href}
/>
</HStack>
) : null}
</VStack>
</HStack>
);
}
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
return (
<PanelCard
title="What needs your attention"
subtitle="Read from this period's activity, most urgent first"
resource={resource}
loading={<SkeletonRows count={3} height={72} />}
empty={
<EmptyPanel
icon="insight"
title="Nothing needs attention"
description="Findings appear here when an activity's performance moves enough to act on."
/>
}
>
{(insights) => (
<VStack gap={0}>
{insights.map((i) => (
<InsightRow key={i.id} insight={i} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -7,8 +7,22 @@ import {
useFootfallReport,
useRecentVisits,
} from './useReports';
import type {Kpi} from '@/features/dashboard/types/dashboard';
import type {Granularity, Kpi} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
import {useSampleResource} from '@/shared/mocks/withSample';
import {
buildActivity,
buildActivityMetrics,
buildInsights,
buildKpis as buildSampleKpis,
buildPeakHours,
buildPeriodPerformance,
buildRewardUsage,
buildStoreComparison,
buildTimeseries,
} from '@/shared/mocks/sampleData';
import {useScope} from '@/shared/hooks/useScope';
import type {Scope} from '@/shared/services/httpClient';
import type {
ConversionReport,
FootfallReport,
@@ -86,3 +100,75 @@ export function useDashboardKpis(
} as Resource<Kpi[]>;
}, [footfall, conversion]);
}
// ---------------------------------------------------------------------------
// SAMPLE panels, ported from the merchant app.
//
// The platform has no endpoint for any of these, so they read
// shared/mocks/sampleData.ts locally — no fake API route. Every panel that
// renders them sits inside a <SampleScope> and wears the "Sample data" token.
// When a real endpoint lands: add it to a repository, swap the hook body for
// useResource(), and delete the builder from sampleData.ts.
// ---------------------------------------------------------------------------
type SampleScopeArg = Pick<Scope, 'storeId' | 'range'>;
function useActiveScope(scope?: SampleScopeArg): SampleScopeArg {
const active = useScope();
return scope ?? active;
}
export function useSampleKpis(scope?: SampleScopeArg) {
const {storeId, range} = useActiveScope(scope);
return useSampleResource((now) => buildSampleKpis(range, storeId, now), [range, storeId]);
}
export function useDashboardTimeseries(scope?: SampleScopeArg) {
const {storeId, range} = useActiveScope(scope);
return useSampleResource((now) => buildTimeseries(range, storeId, now), [range, storeId]);
}
export function useDashboardPeakHours(scope?: SampleScopeArg) {
const {storeId} = useActiveScope(scope);
return useSampleResource(() => buildPeakHours(storeId), [storeId]);
}
export function useDashboardActivity(scope?: SampleScopeArg) {
const {storeId} = useActiveScope(scope);
return useSampleResource((now) => buildActivity(storeId, now), [storeId]);
}
export function useDashboardRewardUsage(scope?: SampleScopeArg) {
const {storeId, range} = useActiveScope(scope);
return useSampleResource(() => buildRewardUsage(storeId, range), [storeId, range]);
}
export function useDashboardStoreComparison(scope?: SampleScopeArg) {
const {range} = useActiveScope(scope);
return useSampleResource((now) => buildStoreComparison(range, now), [range]);
}
export function useDashboardPerformance(granularity: Granularity) {
const {storeId} = useScope();
return useSampleResource(
(now) => buildPeriodPerformance(granularity, storeId, now),
[granularity, storeId],
);
}
/**
* All ten activities, always. The component filters to `isFeatured`, so the
* dashboard summary and the LYTs programme cannot report different counts.
*/
export function useActivityMetrics(scope?: SampleScopeArg) {
const {storeId, range} = useActiveScope(scope);
return useSampleResource(
(now) => buildActivityMetrics(range, storeId, now),
[range, storeId],
);
}
export function useStoreInsights(scope?: SampleScopeArg) {
const {storeId, range} = useActiveScope(scope);
return useSampleResource((now) => buildInsights(range, storeId, now), [range, storeId]);
}

View File

@@ -1,124 +0,0 @@
/**
* TEMPORARY sample data for the dashboard charts.
*
* ── Why this exists ──────────────────────────────────────────────────────
* A new merchant's reports come back empty or near-empty, and an empty axis
* teaches nothing about what the panel is for. Until the platform has history
* for a shop, each chart falls back to this sample and wears a "Sample data"
* token so it can never be mistaken for the merchant's own numbers.
*
* ── Rules ────────────────────────────────────────────────────────────────
* - Real data always wins — see `shared/mocks/withSample.ts`.
* - The KPI row never uses this — a headline number must be real or absent.
* - Values are deterministic (no Math.random) so SSR and hydration agree.
*
* ── Removing it ──────────────────────────────────────────────────────────
* See `shared/mocks/withSample.ts` — one procedure for every mock folder.
*/
import type {
ConversionReport,
FootfallReport,
ReportBucket,
} from '@/features/dashboard/types/reports';
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** A smooth, repeatable wave: weekly rhythm plus a gentle upward drift. */
function wave(i: number, base: number, amp: number): number {
const weekly = Math.sin((i / 7) * Math.PI * 2 - 1.2);
const drift = i * 0.012;
const ripple = Math.sin(i * 1.7) * 0.18;
return Math.max(0, Math.round(base * (1 + drift) + amp * (weekly + ripple)));
}
function dayBuckets(days: number): ReportBucket[] {
// Fixed anchor so the labels are stable across renders and runtimes.
const start = Date.UTC(2026, 7, 27);
return Array.from({length: days}, (_, i) => {
const d = new Date(start + i * 86_400_000);
const visitors = wave(i, 140, 42);
const newVisitors = Math.round(visitors * (0.38 + 0.06 * Math.sin(i / 3)));
const purchases = Math.round(visitors * (0.22 + 0.04 * Math.sin(i / 4 + 1)));
const revenue = purchases * (620 + Math.round(90 * Math.sin(i / 5)));
return {
label: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, '0')}`,
visitors,
newVisitors,
returningVisitors: visitors - newVisitors,
purchases,
revenue,
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
};
});
}
function weekBuckets(days: ReportBucket[]): ReportBucket[] {
const weeks: ReportBucket[] = [];
for (let i = 0; i < days.length; i += 7) {
const slice = days.slice(i, i + 7);
const sum = (k: keyof ReportBucket) =>
slice.reduce((acc, b) => acc + (b[k] as number), 0);
const visitors = sum('visitors');
const purchases = sum('purchases');
weeks.push({
label: `W${Math.floor(i / 7) + 1}`,
visitors,
newVisitors: sum('newVisitors'),
returningVisitors: sum('returningVisitors'),
purchases,
revenue: sum('revenue'),
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
});
}
return weeks;
}
const DAYS = dayBuckets(30);
const WEEKS = weekBuckets(DAYS);
function footfallFrom(buckets: ReportBucket[]): FootfallReport {
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
// Unique people < visit events: regulars come more than once.
const total = Math.round(visits * 0.46);
return {
total,
newVisitors,
returningVisitors: Math.max(0, Math.round(total * 0.9) - newVisitors),
visits,
buckets,
timezone: null,
previousTotal: Math.round(total * 0.88),
};
}
function conversionFrom(buckets: ReportBucket[], total: number): ConversionReport {
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
return {
purchases,
revenue,
conversionPct: Number(((purchases / total) * 100).toFixed(1)),
basketSize: Math.round(revenue / purchases),
buckets,
timezone: null,
previousPurchases: Math.round(purchases * 0.91),
previousRevenue: Math.round(revenue * 0.86),
};
}
export const MOCK_FOOTFALL_DAILY = footfallFrom(DAYS);
export const MOCK_FOOTFALL_WEEKLY = footfallFrom(WEEKS);
export const MOCK_CONVERSION_DAILY = conversionFrom(DAYS, MOCK_FOOTFALL_DAILY.total);
export const MOCK_CONVERSION_WEEKLY = conversionFrom(WEEKS, MOCK_FOOTFALL_WEEKLY.total);
/** True when the report carries at least one non-zero value for `key`. */
export function hasSignal(
report: {buckets: ReportBucket[]} | undefined,
key: keyof Omit<ReportBucket, 'label'>,
): boolean {
return !!report && report.buckets.some((b) => b[key] > 0);
}
export {withSample} from '@/shared/mocks/withSample';

View File

@@ -0,0 +1,100 @@
/**
* Domain rules for the activity layer.
*
* Two things live here that would otherwise be duplicated in every panel that
* renders an activity: which glyph an activity gets, and how its impact chain
* is read out. Both are decisions about the DOMAIN, not about a layout — the
* dashboard summary, the analytics page and a campaign row must all describe
* Spin the same way, or the merchant is looking at three products.
*/
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
import type {
ActivityGroup,
ActivityId,
ActivityMetric,
} from '@/features/dashboard/types/intelligence';
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
walk: ICONS.activityWalk,
visit: ICONS.activityVisit,
selfie: ICONS.activitySelfie,
spin: ICONS.activitySpin,
scratch: ICONS.activityScratch,
brand: ICONS.activityBrand,
challenge: ICONS.activityChallenge,
friend: ICONS.activityFriend,
shop: ICONS.activityShop,
event: ICONS.activityEvent,
};
/**
* The three questions the grouping answers. The description is the point of
* the group — a heading that only says "Engagement" tells a merchant nothing
* they could not have guessed from the cards under it.
*/
export const ACTIVITY_GROUPS: {
id: ActivityGroup;
label: string;
question: string;
}[] = [
{
id: 'engagement',
label: 'Engagement',
question: 'What are customers doing in and around the store?',
},
{
id: 'growth',
label: 'Growth',
question: 'What is bringing new people in?',
},
{
id: 'commerce',
label: 'Commerce',
question: 'Is any of it converting into business?',
},
];
/** A step in the activity → customer → return → purchase chain. */
export interface ImpactStep {
label: string;
value: number;
}
/**
* The impact chain as an ordered list, ready to render.
*
* `rewardClaims` is dropped rather than zeroed when an activity grants no
* reward: a chain reading "→ 0 reward claims" states a failure where there is
* only an absence, and a merchant reads those very differently.
*
* `compact` drops the middle of the chain for the dashboard's small cards,
* which have room for the two ends of the story and not the whole of it. The
* full chain always survives on /activity — this trims the summary, it never
* decides what the data contains.
*/
export function impactChain(
m: ActivityMetric,
{compact = false}: {compact?: boolean} = {},
): ImpactStep[] {
const steps: ImpactStep[] = [
{label: 'customers', value: m.impact.customers},
];
if (!compact && m.impact.rewardClaims !== undefined) {
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
}
if (!compact) {
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
}
steps.push({label: 'purchases', value: m.impact.purchases});
return steps;
}
/** Share of participants who ended up buying — the number the chain exists for. */
export function conversionPct(m: ActivityMetric): number {
if (m.impact.customers === 0) return 0;
return (m.impact.purchases / m.impact.customers) * 100;
}

View File

@@ -0,0 +1,184 @@
/**
* Store intelligence contracts — the activity layer.
*
* Sits beside dashboard.ts and answers a different question. Those types
* describe what the STORE did (visitors, revenue, conversion). These describe
* what CUSTOMERS did, and — the part that makes it intelligence rather than a
* leaderboard — what each of those things was worth.
*
* THE ONE RULE THIS FILE ENCODES
* An activity count on its own is a vanity metric. 724 spins tells a merchant
* nothing they can act on. So `count` never travels alone: every activity
* carries an `ActivityImpact` describing the chain
*
* activity → customers → return visits → purchases
*
* and every surface that renders an activity is expected to show at least one
* downstream step. The numbers behind this are fixtures today; the SHAPE is
* the contract a real backend has to satisfy, and it is deliberately not
* "count plus a delta" — that would let the useful half be dropped silently.
*/
import type {BrandAccent} from '@/shared/utils/accent';
/**
* How an impact figure was arrived at. Required, never defaulted.
*
* This is the most important field in the file. Today every number in the
* chain below is MODELLED — a share of observed footfall pushed through fixed
* conversion rates — and a modelled ₹6.5L rendered in the same type as a real
* one is how a dashboard quietly starts lying. Making the basis part of the
* payload means the UI can label it honestly without guessing, and a backend
* that later joins real till receipts to real activity events flips this to
* `'observed'` and the disclosure disappears on its own. Nothing else has to
* change: the field names already say "attributed", not "earned".
*
* estimated modelled from observed activity and purchase patterns
* observed each purchase is joined to a specific activity event
*/
export type AttributionBasis = 'estimated' | 'observed';
/**
* The one sentence the UI shows wherever an attributed figure appears. Defined
* once so the disclosure cannot drift between the three panels that carry it.
*/
export const ATTRIBUTION_NOTE =
'Attribution is estimated from observed customer activity and purchase patterns. It shows association, not proven cause.';
/**
* The complete activity ecosystem. The dashboard summarises the six marked
* `isFeatured`; all ten live on /activity.
*/
export type ActivityId =
| 'walk'
| 'visit'
| 'selfie'
| 'spin'
| 'scratch'
| 'brand'
| 'challenge'
| 'friend'
| 'shop'
| 'event';
/**
* What the merchant is asking when they look at a group.
*
* engagement "what are customers doing?"
* growth "what is bringing people in?"
* commerce "is any of it converting?"
*/
export type ActivityGroup = 'engagement' | 'growth' | 'commerce';
/**
* The chain that connects an interaction to money.
*
* Read every field after `customers` as ATTRIBUTED rather than caused. A
* customer who spun the wheel and later bought something is an association;
* whether the spin is why they bought is not knowable from this data, and the
* UI must not phrase it as though it were.
*/
export interface ActivityImpact {
/** Distinct customers behind the interactions. Directly observed. */
customers: number;
/** Rewards claimed off the back of it. Absent where the activity grants none. */
rewardClaims?: number;
/** Participants who returned to the store within the period. */
repeatVisits: number;
/** Purchases by those participants, attributed to this activity. */
purchases: number;
/**
* Revenue on those purchases. Named `attributed` rather than `revenue`
* because that is what it is — see AttributionBasis.
*/
attributedRevenueInr: number;
attribution: AttributionBasis;
}
/**
* Whether the merchant is currently running the activity.
*
* Lives on the shared metric rather than on a Lyts-only type: the dashboard
* and the Lyts catalogue must never disagree about whether Spin is live, and
* two types would guarantee that they eventually do.
*/
export type ActivityStatus = 'live' | 'paused' | 'draft';
export interface ActivityMetric {
id: ActivityId;
label: string;
/** One line on what the activity is — the merchant may not have run it yet. */
description: string;
group: ActivityGroup;
/**
* Fixed per activity, carried on the payload rather than derived from grid
* position, so an activity keeps its colour across every screen it appears
* on and a reordered grid does not repaint.
*/
accent: BrandAccent;
/** Interactions recorded in the period — the headline number. */
count: number;
deltaPct: number;
/**
* Whether the activity is running. The dashboard summary does not show this
* — a summary of six live activities does not need six "Live" badges — but
* the Lyts catalogue is where a merchant turns things on and off, so it is
* the same field rather than a second source of truth.
*/
status: ActivityStatus;
/**
* LYTs issued through this activity in the period. 1 LYT = ₹1, so this is
* simultaneously a count and the rupee liability the activity created —
* which is what connects the activity model to the Lyts programme.
*
* Directly observed, unlike everything in `impact` past `customers`: the
* ledger knows exactly how many LYTs it issued and why.
*/
lytsIssued: number;
impact: ActivityImpact;
/** Surfaced on the dashboard summary. The rest are /activity only. */
isFeatured: boolean;
}
export type JourneyStageId =
| 'visit'
| 'engage'
| 'purchase'
| 'return'
| 'refer';
export interface JourneyStage {
id: JourneyStageId;
label: string;
/** Customers who reached this stage. Monotonically decreasing by definition. */
value: number;
/**
* Share of the PREVIOUS stage that made it here. Undefined on the first
* stage, which has nothing to convert from.
*/
conversionPct?: number;
}
export type CampaignStatus = 'live' | 'ended' | 'scheduled';
export interface CampaignStep {
label: string;
value: number;
}
export interface CampaignSummary {
id: string;
name: string;
/** The activity it runs on — gives the row its icon and accent. */
activityId: ActivityId;
accent: BrandAccent;
status: CampaignStatus;
/**
* Participants → engagement → conversion, in order. A campaign that is not
* a funnel is not a campaign, so this is a list rather than named fields:
* a referral drive ends at "new customers", an event ends at "purchases".
*/
steps: CampaignStep[];
attributedRevenueInr: number;
attribution: AttributionBasis;
}

View File

@@ -18,7 +18,7 @@ import {AttributionNote} from '@/features/engagement/components/AttributionNote'
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_ACTIVITIES} from '@/features/engagement/mocks/engagementMock';
import {MOCK_ACTIVITIES} from '@/shared/mocks/sampleData';
import type {ActivityRow} from '@/features/engagement/types/engagement';
/** Null is "not reported", which is not the same fact as zero. */

View File

@@ -15,7 +15,7 @@ import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_CAMPAIGNS} from '@/features/engagement/mocks/engagementMock';
import {MOCK_CAMPAIGNS} from '@/shared/mocks/sampleData';
import type {Campaign} from '@/features/engagement/types/engagement';
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */

View File

@@ -10,7 +10,7 @@ import {formatCompact} from '@/shared/utils/format';
import {useJourney} from '@/features/engagement/hooks/useEngagement';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_JOURNEY} from '@/features/engagement/mocks/engagementMock';
import {MOCK_JOURNEY} from '@/shared/mocks/sampleData';
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
/**

View File

@@ -1,114 +0,0 @@
/**
* TEMPORARY sample data for the engagement panels (activities, campaigns,
* journey), shown only while the platform returns none for this shop.
*
* Attribution stays `'estimated'` on every row, so the AttributionNote still
* renders — sample data obeys the same disclosure rule as real data.
*
* Removal: see `shared/mocks/withSample.ts`.
*/
import type {
ActivityRow,
Campaign,
Journey,
} from '@/features/engagement/types/engagement';
export const MOCK_ACTIVITIES: ActivityRow[] = [
{
id: 'sample-selfie',
kind: 'selfie',
name: 'Selfie wall',
isActive: true,
events: 412,
participants: 318,
anonymous: 24,
repeatVisitors: 142,
purchases: 96,
attributedRevenue: 61_400,
currency: 'INR',
attribution: 'estimated',
},
{
id: 'sample-spin',
kind: 'spin',
name: 'Spin the wheel',
isActive: true,
events: 286,
participants: 231,
anonymous: 12,
repeatVisitors: 118,
purchases: 74,
attributedRevenue: 44_900,
currency: 'INR',
attribution: 'estimated',
},
{
id: 'sample-challenge',
kind: 'challenge',
name: 'Weekend challenge',
isActive: true,
events: 138,
participants: 102,
anonymous: 0,
repeatVisitors: 61,
purchases: 38,
attributedRevenue: 27_300,
currency: 'INR',
attribution: 'estimated',
},
{
id: 'sample-referral',
kind: 'referral',
name: 'Refer a friend',
isActive: false,
events: 54,
participants: 47,
anonymous: 0,
repeatVisitors: 29,
purchases: 18,
attributedRevenue: 12_800,
currency: 'INR',
attribution: 'estimated',
},
];
export const MOCK_CAMPAIGNS: Campaign[] = [
{
id: 'sample-festive',
name: 'Festive week',
startsAt: '2026-09-15T00:00:00Z',
endsAt: null,
isActive: true,
activities: ['selfie', 'spin'],
events: 520,
participants: 388,
purchasers: 131,
attributedRevenue: 82_600,
attribution: 'estimated',
},
{
id: 'sample-monsoon',
name: 'Monsoon rewards',
startsAt: '2026-08-28T00:00:00Z',
endsAt: '2026-09-10T00:00:00Z',
isActive: false,
activities: ['challenge'],
events: 214,
participants: 163,
purchasers: 57,
attributedRevenue: 34_100,
attribution: 'estimated',
},
];
export const MOCK_JOURNEY: Journey = {
stages: [
{id: 'visit', label: 'Visited', customers: 1_240},
{id: 'engage', label: 'Took part', customers: 562},
{id: 'buy', label: 'Bought', customers: 298},
{id: 'return', label: 'Came back', customers: 211},
{id: 'refer', label: 'Referred', customers: 47},
],
attribution: 'estimated',
};

View File

@@ -1,126 +1,104 @@
'use client';
import {useState} from 'react';
import {motion, AnimatePresence} from 'framer-motion';
import {useEffect, useRef} from 'react';
import {StackItem, VStack} from '@astryxdesign/core/Layout';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {Composer} from './Composer';
import {EmptyState} from './EmptyState';
import {SuggestionChips} from './SuggestionChips';
import {MessageList} from './MessageList';
/** Readable line length for replies, and a matching composer. */
const CONTENT_MAX = 768;
/** Within this distance of the bottom, the view follows a streaming reply. */
const FOLLOW_SLACK_PX = 160;
/**
* ChatGPT & Gemini style two-stage chat experience:
* The conversation body: ONE layout in every state and at every width.
*
* 1. STAGE 1 — Initial Minimal Landing:
* - Vertically centered Loyaly logo mark, heading, subtitle, and composer.
* - No suggestion chips shown initially to keep interface calm and minimal.
* ┌──────────────────────────┐
* │ scroll region (fills) │ landing, or the message list
* │ │
* ├──────────────────────────┤
* │ composer (docked) │ never moves, never scrolls away
* └──────────────────────────┘
*
* 2. STAGE 2 — Input Focused / Typing:
* - Focusing or typing in composer smoothly animates suggestion chips into view directly BELOW the composer.
* Why this shape, and what it replaced. The landing used to put the composer
* INSIDE the content, after the insight cards. Its position then depended on
* how tall the content happened to be: on a tall panel it floated mid-screen
* over a slab of dead space, on a short phone it scrolled off the bottom.
* The active chat docked it at the bottom, so the two states also jumped.
* Docking it always makes the position a property of the layout, not of the
* content, which is what holds on every device.
*
* 3. Active Conversation Mode:
* - First message sent removes landing elements and morphs composer to sticky bottom.
* `size="fill"` + `isScrollable` is a complete scroll region — StackItem
* applies the `min-height: 0` reset that the old `h-full` child lacked (that
* made the body a header-height taller than the panel and clipped it).
*
* 4. New Chat:
* - Returns to STAGE 1 (minimal landing, chips hidden until focused again).
* The landing centres itself vertically when there is room and scrolls from
* the top when there is not; `minHeight="100%"` is what lets both happen.
* Suggestion chips live on the landing only — the second copy that used to
* appear under the composer on focus duplicated "Quick queries".
*
* New messages and streaming text pull the view to the bottom, but only when
* the reader is already near it — someone who scrolled up to re-read an
* earlier answer is left where they are.
*/
export function Conversation() {
const {conversation, draft, setDraft, send, stop, isStreaming} =
useLoyalyAi();
const [isFocused, setIsFocused] = useState(false);
const {conversation, draft, setDraft, send, stop, isStreaming} = useLoyalyAi();
const hasMessages = conversation.messages.length > 0;
const showChips = isFocused || draft.trim().length > 0;
const scrollRef = useRef<HTMLElement>(null);
const last = conversation.messages.at(-1);
// Changes on every new message AND on every streamed chunk.
const tail = last ? `${last.id}:${JSON.stringify(last.parts).length}` : '';
const lastRole = last?.role;
useEffect(() => {
const el = scrollRef.current;
if (!el || !tail) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
// The user's own message always scrolls into view; a reply follows only
// if they were already reading the bottom.
if (lastRole === 'user' || distance < FOLLOW_SLACK_PX) {
el.scrollTop = el.scrollHeight;
}
}, [tail, lastRole]);
return (
<div className="flex-1 flex flex-col h-full w-full relative overflow-hidden bg-background">
<AnimatePresence>
{!hasMessages ? (
<motion.div
key="empty-landing-view"
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 0.96, transition: {duration: 0.2}}}
transition={{duration: 0.3, ease: 'easeOut'}}
className="flex-1 flex flex-col items-center px-4 py-6 w-full max-w-2xl mx-auto overflow-y-auto"
<StackItem size="fill" className="bg-background">
<VStack height="100%">
<StackItem size="fill" isScrollable ref={scrollRef}>
<VStack
minHeight="100%"
vAlign={hasMessages ? 'start' : 'center'}
paddingInline={4}
paddingBlock={5}
>
<div className="w-full flex flex-col items-center py-1">
<EmptyState onPick={send} />
<VStack gap={4} width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
{hasMessages ? (
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
) : (
<EmptyState onPick={send} />
)}
</VStack>
</VStack>
</StackItem>
{/* Chat Composer (36px gap from subtitle) */}
<motion.div
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
className="w-full max-w-xl mt-9"
>
<Composer
value={draft}
onChange={setDraft}
onSubmit={send}
onStop={stop}
isStreaming={isStreaming}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</motion.div>
{/* STAGE 2 — Suggestion Chips animate in BELOW the composer when focused or typing */}
<AnimatePresence>
{showChips && (
<motion.div
key="stage-2-chips"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.22, ease: 'easeOut'}}
className="w-full max-w-xl mt-[28px]"
>
<SuggestionChips onPick={send} />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
) : (
<motion.div
key="active-chat-view"
/*
* No initial/animate opacity here.
*
* The composer below shares a `layoutId` with the one in the empty
* view, and framer defers an entering element's `animate` while it
* projects a shared layout across the swap — which left this view
* mounted at `opacity: 0` with the conversation invisible behind
* it (measured: two messages in the DOM, blank panel). The morph
* itself already carries the continuity this fade was for.
*/
className="flex-1 flex flex-col h-full w-full relative overflow-hidden"
>
<div className="flex-1 overflow-y-auto px-4 py-6 w-full">
<div className="max-w-4xl mx-auto w-full space-y-4">
<MessageList
messages={conversation.messages}
isStreaming={isStreaming}
/>
</div>
</div>
<motion.div
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
className="p-3 border-t border-border bg-popover/90 backdrop-blur-md sticky bottom-0 z-10 w-full shrink-0"
>
<div className="max-w-3xl mx-auto w-full">
<Composer
value={draft}
onChange={setDraft}
onSubmit={send}
onStop={stop}
isStreaming={isStreaming}
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
<VStack
paddingInline={3}
paddingBlock={3}
className="shrink-0 border-t border-border bg-surface"
>
<VStack width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
<Composer
value={draft}
onChange={setDraft}
onSubmit={send}
onStop={stop}
isStreaming={isStreaming}
/>
</VStack>
</VStack>
</VStack>
</StackItem>
);
}

View File

@@ -1,22 +1,18 @@
'use client';
import {useState} from 'react';
import {motion} from 'framer-motion';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Token} from '@astryxdesign/core/Token';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {SAMPLE_AI_INSIGHTS, SAMPLE_AI_PULSE} from '@/shared/mocks/sampleData';
const GREETINGS = [
'Ready to optimize your stores?',
'What would you like to analyze today?',
'Need proactive retail insights?',
'Explore your store intelligence',
"Let's boost today's footfall & sales",
];
interface InsightCardItem {
export interface InsightCardItem {
id: string;
tag: string;
tagColor: 'cool' | 'warm';
@@ -27,147 +23,124 @@ interface InsightCardItem {
icon: keyof typeof ICONS;
}
const ACTIVE_INSIGHTS: InsightCardItem[] = [
{
id: 'peak-hours',
tag: 'Traffic Optimization',
tagColor: 'cool',
title: 'Peak Footfall Window Detected',
description: 'Traffic consistently surges +28% on weekends from 2 PM – 5 PM. Adjusting shop-floor staffing can increase conversion by ~14%.',
actionText: 'Analyze Store Hours',
prompt: 'How can I optimize store hours and staffing shifts during peak weekend footfall?',
icon: 'activityEvent',
},
{
id: 'campaign-roi',
tag: 'Reward Performance',
tagColor: 'warm',
title: 'Spin & Win Loyalty Surge',
description: '34% repeat visits recorded from the recent loyalty campaign. Attributed revenue up +₹42,000.',
actionText: 'View Campaign ROI',
prompt: 'Show me the performance of the Spin & Win campaign and recommend settings for this weekend.',
icon: 'lyts',
},
];
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
const [greeting] = useState(GREETINGS[0]);
const SAMPLE_NOTE = 'Example figures — the assistant has no live store feed yet';
/** Small caps label above a group — "ACTIVE INSIGHTS". */
function GroupLabel({icon, children}: {icon: keyof typeof ICONS; children: string}) {
return (
<div className="flex flex-col items-center w-full max-w-xl mx-auto space-y-5 pt-2">
{/* 1. Official Loyaly Heart Logo with Glow */}
<div className="flex flex-col items-center text-center">
<div className="relative mb-3 flex justify-center group cursor-pointer pt-1">
<div className="absolute -inset-2 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-full blur-md opacity-75 group-hover:opacity-100 transition-opacity pointer-events-none" />
<BrandMark size={52} priority />
</div>
<motion.div
key={greeting}
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.25, ease: 'easeOut'}}
className="flex flex-col items-center"
>
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-bold tracking-tight">
{greeting}
</Heading>
<Text type="supporting" justify="center" className="text-xs sm:text-sm mt-1 max-w-sm">
Your proactive AI copilot for smarter retail decisions and instant analytics.
</Text>
</motion.div>
</div>
{/* 2. Live Telemetry Strip */}
<div className="w-full flex items-center justify-between px-3.5 py-1.5 rounded-full bg-zinc-500/5 dark:bg-zinc-400/5 border border-border text-[11px] text-secondary">
<div className="flex items-center gap-1.5 font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Live Store Stream</span>
</div>
<div className="flex items-center gap-3">
<span>Footfall: <strong className="text-primary font-semibold">+18.4%</strong></span>
<span>Sync: <strong className="text-emerald-500 font-semibold">99.8%</strong></span>
</div>
</div>
{/* 3. Proactive Active Insights */}
<div className="w-full space-y-2.5 text-left">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold uppercase tracking-wider text-secondary flex items-center gap-1.5">
<Icon icon={ICONS.insight} size="xsm" />
Active Insights
</span>
<span className="text-[11px] text-zinc-400">Real-time</span>
</div>
<div className="grid grid-cols-1 gap-2.5">
{ACTIVE_INSIGHTS.map((item) => {
const isCool = item.tagColor === 'cool';
return (
<div
key={item.id}
className="group relative p-3.5 rounded-xl border border-border bg-card hover:border-border-emphasized transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer"
onClick={() => onPick?.(item.prompt)}
>
<div className="flex items-start gap-3">
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${
isCool
? 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
}`}
>
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="xsm" color="inherit" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-semibold text-primary truncate">
{item.title}
</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-medium ${
isCool
? 'bg-violet-500/10 text-violet-600 dark:text-violet-300'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
}`}
>
{item.tag}
</span>
</div>
<p className="text-[11px] text-secondary leading-relaxed line-clamp-2">
{item.description}
</p>
<div className="mt-2 flex items-center gap-1 text-[11px] font-medium text-primary group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
<span>{item.actionText}</span>
<Icon icon={ICONS.arrowRight} size="xsm" />
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 4. Quick Suggestion Action Chips */}
<div className="w-full text-left space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider text-secondary px-1 flex items-center gap-1.5">
<Icon icon={ICONS.chat} size="xsm" />
Quick Queries
</span>
<div className="flex flex-wrap gap-1.5">
{SUGGESTIONS.slice(0, 5).map((s) => (
<button
key={s.id}
type="button"
className="px-3 py-1.5 text-xs rounded-full border border-border bg-card text-secondary hover:text-primary hover:bg-muted hover:border-border-emphasized transition-all duration-150 cursor-pointer shadow-xs active:scale-95"
onClick={() => onPick?.(s.prompt)}
>
{s.label}
</button>
))}
</div>
</div>
</div>
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS[icon]} size="xsm" color="secondary" />
<Text size="xsm" weight="semibold" color="secondary" className="uppercase tracking-wider">
{children}
</Text>
</HStack>
);
}
/**
* The assistant's landing: greeting, store pulse, insight cards, quick
* queries. The composer is NOT here — Conversation docks it below the scroll
* region so it sits in the same place on every screen.
*
* Built from design-system components end to end so it reflows by itself:
* cards stretch to the panel, the pulse strip and the chip row wrap on a
* narrow phone, and nothing carries a fixed pixel size or a raw colour. The
* two brand accents come only through ACCENT (see CLAUDE.md).
*
* The pulse figures and the insight cards are SAMPLE (shared/mocks/
* sampleData.ts), and say so — they used to be labelled "Live" and
* "Real-time" while being constants.
*/
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
return (
<VStack gap={6} width="100%">
<VStack gap={2} hAlign="center">
<BrandMark size={48} priority />
<Heading level={2} justify="center">
Ready to optimize your stores?
</Heading>
<Text size="sm" color="secondary" justify="center">
Your proactive AI copilot for smarter retail decisions and instant analytics.
</Text>
</VStack>
<HStack
gap={3}
vAlign="center"
hAlign="between"
wrap="wrap"
paddingInline={4}
paddingBlock={2}
className="rounded-full border border-border bg-surface"
>
<HStack gap={4} wrap="wrap">
<Text size="sm" color="secondary">
Footfall{' '}
<Text type="inherit" weight="semibold" color="primary">
+{SAMPLE_AI_PULSE.footfallDeltaPct}%
</Text>
</Text>
<Text size="sm" color="secondary">
Sync{' '}
<Text type="inherit" weight="semibold" color="primary">
{SAMPLE_AI_PULSE.syncPct}%
</Text>
</Text>
</HStack>
<SampleTag show description={SAMPLE_NOTE} />
</HStack>
<VStack gap={3}>
<HStack hAlign="between" vAlign="center" gap={2}>
<GroupLabel icon="insight">Active insights</GroupLabel>
<SampleTag show description={SAMPLE_NOTE} />
</HStack>
{SAMPLE_AI_INSIGHTS.map((item) => (
<ClickableCard
key={item.id}
label={`${item.title}. ${item.actionText}`}
onClick={() => onPick?.(item.prompt)}
padding={4}
>
<HStack gap={3} vAlign="start">
<HStack
hAlign="center"
vAlign="center"
className={`size-8 shrink-0 rounded-md ${ACCENT[item.tagColor].chip}`}
>
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="sm" color="inherit" />
</HStack>
<VStack gap={1.5} className="min-w-0 flex-1">
<HStack gap={2} hAlign="between" vAlign="start" wrap="wrap">
<Text size="sm" weight="semibold">
{item.title}
</Text>
<Token size="sm" label={item.tag} />
</HStack>
<Text size="sm" color="secondary" className="line-clamp-3">
{item.description}
</Text>
<HStack gap={1} vAlign="center">
<Text size="sm" weight="medium">
{item.actionText}
</Text>
<Icon icon={ICONS.arrowRight} size="xsm" />
</HStack>
</VStack>
</HStack>
</ClickableCard>
))}
</VStack>
<VStack gap={3}>
<GroupLabel icon="chat">Quick queries</GroupLabel>
<HStack gap={2} wrap="wrap">
{SUGGESTIONS.slice(0, 5).map((s) => (
<Token key={s.id} label={s.label} onClick={() => onPick?.(s.prompt)} />
))}
</HStack>
</VStack>
</VStack>
);
}

View File

@@ -4,10 +4,11 @@ import {
ChatMessage as ChatMessageRow,
ChatMessageBubble,
} from '@astryxdesign/core/Chat';
import {VStack} from '@astryxdesign/core/Layout';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {MessageContent} from './MessageContent';
import {TypingIndicator} from './TypingIndicator';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import {ACCENT} from '@/shared/utils/accent';
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
/**
@@ -39,9 +40,13 @@ export function MessageBubble({message}: {message: ChatMessage}) {
density="compact"
avatar={
isAssistant ? (
<div className="w-6 h-6 rounded-full flex items-center justify-center bg-amber-500/10 border border-amber-500/25 shrink-0 mt-0.5 shadow-xs overflow-hidden">
<HStack
hAlign="center"
vAlign="center"
className={`size-6 shrink-0 overflow-hidden rounded-full border border-border ${ACCENT.warm.soft}`}
>
<BrandMark size={16} />
</div>
</HStack>
) : undefined
}
>

View File

@@ -1,42 +0,0 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
/**
* The opening moves, as a wrapped row of chips.
*
* Chips rather than the stacked button list this replaces: six full-width
* secondary buttons read as a menu of commands, which is what made the old
* panel feel like a dashboard. A wrapped row of quiet pills reads as
* suggestions — the same distinction ChatGPT, Claude and Gemini all make.
*
* Hand-built rather than Astryx's Token: Token is a data chip with a
* dismiss/selected vocabulary that does not apply here, and Button carries a
* control's weight. What is wanted is a quiet, fully-rounded, tappable label —
* three token-backed utilities, and no component contract to fight.
*/
const CHIP = [
'rounded-full border border-border bg-card',
'px-3.5 py-2 text-sm text-secondary text-left',
'transition-colors duration-150 cursor-pointer',
'hover:bg-muted hover:text-primary hover:border-border-strong',
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
].join(' ');
export function SuggestionChips({onPick}: {onPick: (prompt: string) => void}) {
return (
<HStack gap={2} wrap="wrap" hAlign="center">
{SUGGESTIONS.map((s) => (
<button
key={s.id}
type="button"
className={CHIP}
onClick={() => onPick(s.prompt)}
>
{s.label}
</button>
))}
</HStack>
);
}

View File

@@ -8,6 +8,7 @@ import {ICONS} from '@/shared/utils/icons';
import type {ReportAction} from '@/features/loyaly-ai/types/chat';
import {exportData} from '@/shared/utils/export/exportManager';
import {SAMPLE_AI_EXPORT_ROWS} from '@/shared/mocks/sampleData';
export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
const router = useRouter();
@@ -36,11 +37,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
{key: 'metric', header: 'Metric Name'},
{key: 'val', header: 'Value'},
],
data: [
{metric: 'Daily Revenue', val: '₹3,40,000'},
{metric: 'Visitors', val: '1,284'},
{metric: 'Purchases', val: '276'},
],
data: SAMPLE_AI_EXPORT_ROWS,
format: 'csv',
});
toast({body: '✓ CSV downloaded successfully'});
@@ -58,11 +55,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
{key: 'metric', header: 'Metric Name'},
{key: 'val', header: 'Value'},
],
data: [
{metric: 'Daily Revenue', val: '₹3,40,000'},
{metric: 'Visitors', val: '1,284'},
{metric: 'Purchases', val: '276'},
],
data: SAMPLE_AI_EXPORT_ROWS,
format: 'pdf',
});
toast({body: '✓ PDF downloaded successfully'});

View File

@@ -3,6 +3,14 @@
import {Badge} from '@astryxdesign/core/Badge';
import type {ReportInsight} from '@/features/loyaly-ai/types/chat';
/**
* One neutral card for every insight type. The type is carried by the Badge,
* which resolves through the semantic tokens; the tinted blue/amber/emerald/
* purple borders it replaced were raw hues the monochrome rule forbids, and
* read as four different components.
*/
const NEUTRAL_CARD = 'border-border bg-surface';
export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
if (!insights || insights.length === 0) return null;
@@ -13,28 +21,28 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
icon: '💡',
badgeVariant: 'info' as const,
label: 'Opportunity',
borderClass: 'border-blue-500/30 bg-blue-500/[0.03]',
borderClass: NEUTRAL_CARD,
};
case 'risk':
return {
icon: '⚠️',
badgeVariant: 'warning' as const,
label: 'Risk Alert',
borderClass: 'border-amber-500/30 bg-amber-500/[0.03]',
borderClass: NEUTRAL_CARD,
};
case 'growth':
return {
icon: '📈',
badgeVariant: 'success' as const,
label: 'Growth Factor',
borderClass: 'border-emerald-500/30 bg-emerald-500/[0.03]',
borderClass: NEUTRAL_CARD,
};
case 'best_performer':
return {
icon: '🏆',
badgeVariant: 'success' as const,
label: 'Top Performer',
borderClass: 'border-purple-500/30 bg-purple-500/[0.03]',
borderClass: NEUTRAL_CARD,
};
}
};
@@ -77,7 +85,7 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
{insight.recommendedAction ? (
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
<span className="text-amber-300 font-bold shrink-0">Action:</span>
<span className="text-secondary font-bold shrink-0">Action:</span>
<span className="text-primary whitespace-normal break-words min-w-0">
{insight.recommendedAction}
</span>

View File

@@ -105,7 +105,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
<span
title={kpi.trend}
className={`text-xs font-bold truncate max-w-full min-w-0 ${
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
isUp ? 'text-success' : isDown ? 'text-error' : 'text-secondary'
}`}
>
{kpi.trend}

View File

@@ -0,0 +1,89 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups';
import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable';
import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard';
import {formatCompact, formatLyt} from '@/shared/utils/format';
/**
* The activity ecosystem, as the Lyts page presents it.
*
* ── Why this lives here and not on the dashboard ──────────────────────────
* The two pages ask different questions of the same data. The dashboard asks
* "how are customers engaging and what is it worth?" and answers with six
* activities and their business impact. Lyts asks "what am I running, what
* does it cost me in LYTs, and is it working?" — which needs all ten, their
* status, and the LYT liability each one creates.
*
* ── One model, not two ────────────────────────────────────────────────────
* Everything here reads `useActivityMetrics()` — the same hook, the same
* endpoint, the same fixtures the dashboard uses. If the dashboard says Visit
* is 35.3k, this page says 35.3k, because there is nowhere for a second
* number to come from. The grouped cards and the impact table are the exact
* components the dashboard's own activity work produced, rendered in
* `catalogue` variant rather than reimplemented.
*
* The summary strip is StatPairs rather than MetricCards on purpose: the KPI
* row directly above it is already four MetricCards about LYT liability, and
* a second row of the same object would read as eight equal headline metrics
* on a page whose headline is the programme, not the activities.
*/
export function ActivityProgramme() {
const metrics = useActivityMetrics();
return (
<VStack gap={5}>
<StaticPanel
title="Customer activities"
subtitle="Every way a customer can earn, and what each one issues in LYTs"
>
{/*
Derived at render rather than fetched as its own summary endpoint.
A totals endpoint could disagree with the cards below it — this
cannot, because it is the same array.
*/}
{metrics.status === 'success' || metrics.status === 'empty' ? (
<StatRow>
<StatPair
label="Live activities"
value={`${metrics.data.filter((m) => m.status === 'live').length} of ${metrics.data.length}`}
/>
<StatPair
label="Total participation"
value={formatCompact(
metrics.data.reduce((a, m) => a + m.count, 0),
)}
/>
<StatPair
label="Customers reached"
value={formatCompact(
// The MAXIMUM, not the sum: one customer who visits, spins and
// takes a selfie is one customer. Adding the per-activity
// counts would claim three, and inflate "customers reached"
// past the store's own footfall.
metrics.data.reduce(
(a, m) => Math.max(a, m.impact.customers),
0,
),
)}
/>
<StatPair
label="LYTs issued"
value={formatLyt(
metrics.data.reduce((a, m) => a + m.lytsIssued, 0),
)}
align="end"
/>
</StatRow>
) : null}
</StaticPanel>
<ActivityGroups resource={metrics} />
<ActivityImpactTable resource={metrics} />
</VStack>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Banner} from '@astryxdesign/core/Banner';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {formatCompact, formatLyt} from '@/shared/utils/format';
import {daysUntil} from '@/features/lyts/services/lytsService';
import type {Reward} from '@/features/lyts/types/reward';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Rewards lapsing within a fortnight.
*
* This panel exists because expiry is the one thing on this page with a
* deadline. Everything else can be read whenever; an offer expiring in two
* days either gets extended today or it doesn't.
*
* Sorted soonest-first, and the description leads with outstanding liability
* rather than claim count — the actionable number is what customers are still
* holding, not how popular the offer was.
*/
const HORIZON_DAYS = 14;
export function ExpiryAlerts({
resource,
nowMs,
}: {
resource: Resource<Reward[]>;
nowMs: number;
}) {
return (
<PanelCard
title="Expiry alerts"
subtitle={`Rewards lapsing in the next ${HORIZON_DAYS} days`}
resource={resource}
loading={<SkeletonRows count={2} height={64} />}
empty={
<EmptyPanel
icon="expiry"
title="Nothing expiring"
description="No active rewards lapse in the next two weeks."
/>
}
>
{(rewards) => {
const soon = rewards
.map((r) => ({r, days: daysUntil(r.expiresAt, nowMs)}))
.filter(
(x): x is {r: Reward; days: number} =>
x.days !== null && x.days >= 0 && x.days <= HORIZON_DAYS,
)
.sort((a, b) => a.days - b.days);
if (soon.length === 0) {
return (
<EmptyPanel
icon="expiry"
title="Nothing expiring"
description="No active rewards lapse in the next two weeks."
/>
);
}
return (
<VStack gap={2}>
{soon.map(({r, days}) => {
const outstanding = r.claimed - r.used;
return (
<Banner
key={r.id}
// Under 3 days is an emergency; the rest is a heads-up.
status={days <= 3 ? 'error' : 'warning'}
title={`${r.name} expires ${days === 0 ? 'today' : days === 1 ? 'tomorrow' : `in ${days} days`}`}
description={`${formatCompact(outstanding)} unredeemed claims · ${formatLyt(outstanding * r.costLyt)} outstanding`}
/>
);
})}
</VStack>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {Badge} from '@astryxdesign/core/Badge';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatLyt, formatPct} from '@/shared/utils/format';
import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus';
import {daysUntil, usageRate} from '@/features/lyts/services/lytsService';
import type {Reward} from '@/features/lyts/types/reward';
/**
* One reward.
*
* The progress bar is claimed-vs-redeemed, and it is the point of the card:
* the unfilled portion is outstanding liability — LYTs a customer holds and
* can spend at any time. A reward claimed 800 times and redeemed 150 is not a
* popular offer, it is ₹78,000 of unbudgeted exposure.
*/
export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
const status = REWARD_STATUS[reward.status];
const rate = usageRate(reward.claimed, reward.used);
const days = daysUntil(reward.expiresAt, nowMs);
const outstanding = reward.claimed - reward.used;
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={reward.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.lyt} size="sm" color="secondary" />
<Text size="base" weight="medium">
{formatLyt(reward.costLyt)}
</Text>
</HStack>
}
actions={<Badge variant={status.variant} label={status.label} />}
/>
<VStack gap={2}>
<ProgressBar
value={rate}
max={100}
label={`Redeemed ${formatPct(rate, 0)} of claims`}
hasValueLabel={false}
/>
<HStack hAlign="between">
<Text size="sm" weight="medium">
{formatCompact(reward.used)} redeemed
</Text>
<Text size="sm" weight="medium">
{formatCompact(reward.claimed)} claimed
</Text>
</HStack>
</VStack>
<Divider />
<StatRow>
<StatPair
label="Outstanding"
value={formatLyt(outstanding * reward.costLyt)}
/>
<StatPair
label={days === null ? 'Expires' : days < 0 ? 'Expired' : 'Expires in'}
value={
days === null
? 'Never'
: days < 0
? `${Math.abs(days)}d ago`
: `${days}d`
}
align="end"
/>
</StatRow>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {FilterBar} from '@/shared/components/patterns/FilterBar';
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {RewardCard} from './RewardCard';
import type {Reward, RewardStatus} from '@/features/lyts/types/reward';
import type {Resource} from '@/shared/hooks/useResource';
type Filter = 'all' | RewardStatus;
const REWARD_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
export function RewardGrid({
resource,
nowMs,
}: {
resource: Resource<Reward[]>;
nowMs: number;
}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// Memoised on resource.data: `?? []` allocates a fresh array every render,
// which would make it an unstable dependency and recompute both memos below
// on every keystroke.
const rewards = useMemo(() => resource.data ?? [], [resource.data]);
// Counts come from the unfiltered set so the chips still read "Expiring (2)"
// while another filter is active.
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: rewards.length,
active: 0,
paused: 0,
expiring: 0,
expired: 0,
};
for (const r of rewards) c[r.status] += 1;
return c;
}, [rewards]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return rewards.filter(
(r) =>
(filter === 'all' || r.status === filter) &&
(q === '' || r.name.toLowerCase().includes(q)),
);
}, [rewards, query, filter]);
return (
<CollectionRegion label="Reward catalogue">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search rewards…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter rewards by status"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'active', label: 'Active', hint: counts.active},
{value: 'expiring', label: 'Expiring', hint: counts.expiring},
{value: 'paused', label: 'Paused', hint: counts.paused},
{value: 'expired', label: 'Expired', hint: counts.expired},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'reward' : 'rewards'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={230} />}
empty={
<EmptyPanel
icon="lyts"
title="No rewards yet"
description="Create a reward to let customers spend their LYTs in store."
/>
}
>
{() =>
visible.length === 0 ? (
<EmptyPanel
icon="lyts"
title="No rewards match"
description="Try a different status filter or clear the search."
/>
) : (
<Grid columns={REWARD_GRID_COLUMNS} gap={4}>
{visible.map((r) => (
<RewardCard key={r.id} reward={r} nowMs={nowMs} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {HStack} from '@astryxdesign/core/Layout';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {formatCompact, formatLyt} from '@/shared/utils/format';
import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus';
import {usageRate} from '@/features/lyts/services/lytsService';
import type {Reward} from '@/features/lyts/types/reward';
import type {Resource} from '@/shared/hooks/useResource';
interface Row extends Record<string, unknown> {
id: string;
name: string;
status: Reward['status'];
costLyt: number;
claimed: number;
used: number;
rate: number;
outstanding: number;
}
/**
* The whole catalogue as rows.
*
* A table rather than more cards: this is the view for comparing eight
* rewards on the same four numbers, and cards force the eye to travel in two
* dimensions to do a one-dimensional comparison. Sorted by redemption rate so
* the offers that aren't working surface at the bottom without hunting.
*/
export function RewardPerformanceTable({
resource,
}: {
resource: Resource<Reward[]>;
}) {
const columns: TableColumn<Row>[] = [
{
key: 'name',
header: 'Reward',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="base" weight="semibold">
{row.name}
</Text>
<Badge
variant={REWARD_STATUS[row.status].variant}
label={REWARD_STATUS[row.status].label}
/>
</HStack>
),
},
{
key: 'costLyt',
header: 'Cost',
width: pixel(104),
align: 'end',
renderCell: (row) => <Text size="base" weight="medium">{formatLyt(row.costLyt)}</Text>,
},
{
key: 'claimed',
header: 'Claimed',
width: pixel(104),
align: 'end',
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.claimed)}</Text>,
},
{
key: 'used',
header: 'Redeemed',
width: pixel(112),
align: 'end',
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.used)}</Text>,
},
{
key: 'rate',
header: 'Rate',
width: pixel(112),
align: 'end',
renderCell: (row) => (
<HStack hAlign="end">
<MetricDelta value={row.rate - 50} isRiseGood size="sm" />
</HStack>
),
},
{
key: 'outstanding',
header: 'Outstanding',
width: pixel(140),
align: 'end',
renderCell: (row) => (
<Text size="base" weight="semibold">{formatLyt(row.outstanding)}</Text>
),
},
];
return (
<PanelCard
title="Reward performance"
subtitle="Sorted by redemption rate — the weakest offers sit at the bottom"
resource={resource}
loading={<SkeletonRows count={6} height={32} />}
empty={
<EmptyPanel
icon="lyts"
title="No rewards to compare"
description="Performance appears once rewards have been claimed."
/>
}
>
{(rewards) => {
const rows: Row[] = rewards
.map((r) => ({
id: r.id,
name: r.name,
status: r.status,
costLyt: r.costLyt,
claimed: r.claimed,
used: r.used,
rate: usageRate(r.claimed, r.used),
outstanding: (r.claimed - r.used) * r.costLyt,
}))
.sort((a, b) => b.rate - a.rate);
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="compact"
/>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import {useScope} from '@/shared/hooks/useScope';
import {useSampleResource} from '@/shared/mocks/withSample';
import {
buildLytActivity,
buildRedemptions,
buildRewards,
} from '@/shared/mocks/sampleData';
/**
* LYT data access — SAMPLE.
*
* The platform has no rewards, redemption or programme-activity resource, so
* these read shared/mocks/sampleData.ts locally. When the endpoints exist,
* swap each body for useResource(lytsRepository.x(useScope())).
*/
export function useRewards() {
const {storeId, range} = useScope();
return useSampleResource((now) => buildRewards(storeId, range, now), [storeId, range]);
}
export function useRedemptions() {
const {storeId, range} = useScope();
return useSampleResource((now) => buildRedemptions(storeId, range, now), [storeId, range]);
}
export function useLytsActivity() {
const {storeId} = useScope();
return useSampleResource((now) => buildLytActivity(storeId, now), [storeId]);
}

View File

@@ -0,0 +1,53 @@
import type {Reward} from '@/features/lyts/types/reward';
/**
* Domain rules for LYT rewards.
*
* The presentation mapping (status → badge tone) stays in utils/rewardStatus —
* it knows about badges. These are the calculations behind it, which know only
* about rewards, and which the expiry panel, the performance table and any
* future export all have to agree on.
*/
/** Whole days until expiry; negative once lapsed, null if it never expires. */
export function daysUntil(
expiresAt: string | null,
nowMs: number,
): number | null {
if (!expiresAt) return null;
return Math.ceil((new Date(expiresAt).getTime() - nowMs) / 86400000);
}
/**
* Claimed → used conversion, as a percentage.
*
* Zero claims is 0%, not NaN: a reward nobody has taken up has a usage rate,
* and it is zero. Rounded to one decimal because the figure is read, not
* computed against.
*/
export function usageRate(claimed: number, used: number): number {
return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1));
}
/**
* Rewards that need a decision, soonest deadline first.
*
* `withinDays` is the merchant's attention window rather than a fixed
* constant — the expiry panel asks for 7, a daily digest would ask for 1.
* Already-expired rewards are excluded: they are history, and mixing them into
* an alert list is how a panel about acting becomes a panel about reading.
*/
export function selectExpiring(
rewards: Reward[],
nowMs: number,
withinDays: number,
): Reward[] {
return rewards
.map((reward) => ({reward, days: daysUntil(reward.expiresAt, nowMs)}))
.filter(
(entry): entry is {reward: Reward; days: number} =>
entry.days !== null && entry.days >= 0 && entry.days <= withinDays,
)
.sort((a, b) => a.days - b.days)
.map((entry) => entry.reward);
}

View File

@@ -0,0 +1,25 @@
import type {RewardStatus} from '@/features/lyts/types/reward';
/**
* Reward status → badge treatment.
*
* Only `expiring` and `expired` carry colour. An active reward is the normal
* case and a paused one is a deliberate choice by the merchant — neither
* needs attention. Colouring all four would leave nothing standing out on a
* page whose entire job is surfacing the ones about to lapse.
*/
export const REWARD_STATUS: Record<
RewardStatus,
{label: string; variant: 'neutral' | 'warning' | 'error'}
> = {
active: {label: 'Active', variant: 'neutral'},
paused: {label: 'Paused', variant: 'neutral'},
expiring: {label: 'Expiring', variant: 'warning'},
expired: {label: 'Expired', variant: 'error'},
};
/*
* daysUntil / usageRate used to live here. They are calculations about
* rewards, not about badges, so they moved to services/lytsService — this
* module is the presentation mapping and nothing else.
*/

View File

@@ -11,6 +11,13 @@ import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {
SAMPLE_API_KEYS,
SAMPLE_WEBHOOKS,
SAMPLE_WEBHOOK_LOGS,
sampleApiKeyPrefix,
sampleWebhookSecret,
} from '@/shared/mocks/sampleData';
export interface ApiKeyItem extends Record<string, unknown> {
id: string;
@@ -38,73 +45,10 @@ export interface WebhookLogItem extends Record<string, unknown> {
timestamp: string;
}
const INITIAL_KEYS: ApiKeyItem[] = [
{
id: 'key-1',
name: 'Production POS Key',
keyPrefix: 'lmer_live_9f8a••••••••4b12',
created: '10 Jan 2026',
lastUsed: 'Just now',
status: 'active',
},
{
id: 'key-2',
name: 'Staging Integration Key',
keyPrefix: 'lmer_test_3d2c••••••••8e91',
created: '15 Jun 2026',
lastUsed: 'Yesterday',
status: 'active',
},
];
const INITIAL_WEBHOOKS: WebhookEndpointItem[] = [
{
id: 'wh-1',
url: 'https://api.nearle.in/webhooks/loyaly',
events: 'order.created, lyt.redeemed',
secret: 'whsec_8841••••••••9932',
status: 'healthy',
},
{
id: 'wh-2',
url: 'https://hooks.zapier.com/hooks/catch/19284',
events: 'staff.checkin, store.updated',
secret: 'whsec_7712••••••••1102',
status: 'healthy',
},
];
const WEBHOOK_LOGS: WebhookLogItem[] = [
{
id: 'log-101',
event: 'lyt.redeemed',
url: 'https://api.nearle.in/webhooks/loyaly',
code: 200,
latency: '142ms',
timestamp: 'Today, 15:42:01',
},
{
id: 'log-102',
event: 'order.created',
url: 'https://api.nearle.in/webhooks/loyaly',
code: 200,
latency: '185ms',
timestamp: 'Today, 15:38:12',
},
{
id: 'log-103',
event: 'staff.checkin',
url: 'https://hooks.zapier.com/hooks/catch/19284',
code: 200,
latency: '95ms',
timestamp: 'Today, 09:01:44',
},
];
export function ApiWebhooksManager() {
const toast = useToast();
const [keys, setKeys] = useState<ApiKeyItem[]>(INITIAL_KEYS);
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(INITIAL_WEBHOOKS);
const [keys, setKeys] = useState<ApiKeyItem[]>(SAMPLE_API_KEYS);
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(SAMPLE_WEBHOOKS);
const [newKeyName, setNewKeyName] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [newWebhookUrl, setNewWebhookUrl] = useState('');
@@ -117,7 +61,7 @@ export function ApiWebhooksManager() {
const createdKey: ApiKeyItem = {
id: `key-${Date.now()}`,
name: newKeyName,
keyPrefix: `lmer_live_${Math.random().toString(36).substring(2, 6)}••••••••${Math.random().toString(36).substring(2, 6)}`,
keyPrefix: sampleApiKeyPrefix(),
created: 'Just now',
lastUsed: 'Never',
status: 'active',
@@ -142,7 +86,7 @@ export function ApiWebhooksManager() {
id: `wh-${Date.now()}`,
url: newWebhookUrl,
events: 'order.created, lyt.redeemed, staff.checkin',
secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`,
secret: sampleWebhookSecret(),
status: 'healthy',
};
setWebhooks((prev) => [newWh, ...prev]);
@@ -374,7 +318,7 @@ export function ApiWebhooksManager() {
>
<ResponsiveTable
columns={logColumns}
data={WEBHOOK_LOGS}
data={SAMPLE_WEBHOOK_LOGS}
primaryKey="event"
/>
</StaticPanel>

View File

@@ -26,36 +26,13 @@ export interface InvoiceItem extends Record<string, unknown> {
downloadUrl: string;
}
const INVOICES: InvoiceItem[] = [
{
id: 'INV-2026-008',
date: '1 Aug 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
{
id: 'INV-2026-007',
date: '1 Jul 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
{
id: 'INV-2026-006',
date: '1 Jun 2026',
amount: 14999,
status: 'paid',
downloadUrl: '#',
},
];
import {DownloadDropdown} from '@/shared/components/patterns/DownloadDropdown';
import {SAMPLE_BANK_ACCOUNT, SAMPLE_INVOICES} from '@/shared/mocks/sampleData';
export function BillingOverview() {
const toast = useToast();
const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842');
const [ifsc, setIfsc] = useState('HDFC0001234');
const [bankAccount, setBankAccount] = useState(SAMPLE_BANK_ACCOUNT.account);
const [ifsc, setIfsc] = useState(SAMPLE_BANK_ACCOUNT.ifsc);
const [isUpdatingBank, setIsUpdatingBank] = useState(false);
const handleUpdateBank = () => {
@@ -220,7 +197,7 @@ export function BillingOverview() {
<StaticPanel title="Invoice Statements & Payment History" subtitle="Download tax invoices and monthly receipts.">
<ResponsiveTable
columns={columns}
data={INVOICES}
data={SAMPLE_INVOICES}
primaryKey="id"
/>
</StaticPanel>

View File

@@ -11,6 +11,7 @@ import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {SAMPLE_BUSINESS} from '@/shared/mocks/sampleData';
export interface BusinessData {
businessName: string;
@@ -26,25 +27,11 @@ export interface BusinessData {
lytsPerHundred: number;
}
const DEFAULT_BUSINESS: BusinessData = {
businessName: 'Loyaly Retail Pvt Ltd',
legalEntity: 'Private Limited Company',
category: 'Retail & Quick Service Restaurant',
gstin: '29AABCL1234M1Z7',
pan: 'AABCL1234M',
phone: '+91 98450 12345',
email: 'aravind@nearle.in',
address: '100 Feet Road, Indiranagar',
city: 'Bengaluru, Karnataka',
pincode: '560038',
lytsPerHundred: 5,
};
export function BusinessForm() {
const toast = useToast();
const [form, setForm] = useState<BusinessData>(DEFAULT_BUSINESS);
const [form, setForm] = useState<BusinessData>(SAMPLE_BUSINESS);
const [isSaving, setIsSaving] = useState(false);
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
const [savedData, setSavedData] = useState<BusinessData>(SAMPLE_BUSINESS);
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
setForm((f) => ({...f, [key]: val}));

View File

@@ -10,6 +10,7 @@ import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {SAMPLE_INTEGRATIONS} from '@/shared/mocks/sampleData';
export interface IntegrationApp {
id: string;
@@ -21,69 +22,9 @@ export interface IntegrationApp {
lastSync?: string;
}
const INITIAL_APPS: IntegrationApp[] = [
{
id: 'shopify',
name: 'Shopify Storefront',
category: 'E-commerce POS',
description: 'Sync online customer orders, cart totals and auto-issue LYT points.',
status: 'connected',
apiKeyLabel: 'myshopify-store.myshopify.com',
lastSync: '5 mins ago',
},
{
id: 'woocommerce',
name: 'WooCommerce',
category: 'E-commerce POS',
description: 'WordPress store integration for online checkout redemption.',
status: 'disconnected',
},
{
id: 'razorpay',
name: 'Razorpay PG',
category: 'Payment Gateway',
description: 'Automated settlement reconciliation and online UPI payment sync.',
status: 'connected',
apiKeyLabel: 'rzp_live_••••••••8841',
lastSync: 'Just now',
},
{
id: 'stripe',
name: 'Stripe Billing',
category: 'Payment Gateway',
description: 'International cards and subscription recurring billing handler.',
status: 'disconnected',
},
{
id: 'whatsapp',
name: 'WhatsApp Business API',
category: 'Messaging Channel',
description: 'Send instant transaction receipts, reward expiry notices and OTPs.',
status: 'connected',
apiKeyLabel: '+91 98450 12345 (Verified)',
lastSync: '2 mins ago',
},
{
id: 'google',
name: 'Google Business Profile',
category: 'Local Search & Maps',
description: 'Sync branch operating hours, store locations and customer reviews.',
status: 'connected',
apiKeyLabel: '5 locations synced',
lastSync: '1 hour ago',
},
{
id: 'meta',
name: 'Meta Ads & Conversions',
category: 'Marketing & Ads',
description: 'Track ad attribution, return on ad spend (ROAS) and retargeting.',
status: 'disconnected',
},
];
export function IntegrationsGrid() {
const toast = useToast();
const [apps, setApps] = useState<IntegrationApp[]>(INITIAL_APPS);
const [apps, setApps] = useState<IntegrationApp[]>(SAMPLE_INTEGRATIONS);
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');

View File

@@ -17,6 +17,7 @@ import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useResource} from '@/shared/hooks/useResource';
import {securityRepository} from '@/features/settings/repositories/securityRepository';
import type {DeviceSession} from '@/features/settings/services/mapSession';
import {SAMPLE_LOGIN_AUDIT} from '@/shared/mocks/sampleData';
export interface ActiveSessionItem extends Record<string, unknown> {
id: string;
@@ -36,33 +37,6 @@ export interface LoginAuditItem extends Record<string, unknown> {
timestamp: string;
}
const AUDIT_LOGS: LoginAuditItem[] = [
{
id: 'aud-1',
user: 'aravind@nearle.in',
ip: '103.15.24.81',
device: 'macOS Chrome',
status: 'success',
timestamp: 'Today, 14:02',
},
{
id: 'aud-2',
user: 'vikram@nearle.in',
ip: '106.51.72.19',
device: 'Windows Chrome',
status: 'success',
timestamp: 'Today, 11:15',
},
{
id: 'aud-3',
user: 'aravind@nearle.in',
ip: '185.220.101.5',
device: 'Unknown Linux Device',
status: 'failed',
timestamp: 'Yesterday, 22:30',
},
];
export function SecurityManager() {
const toast = useToast();
const [twoFactor, setTwoFactor] = useState(true);
@@ -311,7 +285,7 @@ export function SecurityManager() {
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable
columns={auditColumns}
data={AUDIT_LOGS}
data={SAMPLE_LOGIN_AUDIT}
primaryKey="user"
/>
</StaticPanel>

View File

@@ -0,0 +1,65 @@
'use client';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {CHART} from '@/shared/components/charts/palette';
import {formatCompact, formatDayLabel} from '@/shared/utils/format';
import type {AttendancePoint} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Daily attendance, stacked.
*
* PRESENT IS GRAY. Colouring it green produced a wall of saturated bars that
* dominated the page and buried the only thing worth looking at — the amber
* and red slivers. Present is the baseline, not an event; the exceptions are
* what a merchant scans for, and they are what gets colour.
*
* This keeps the app's rule intact (colour marks what needs action) while
* still letting late and absent read instantly without a legend lookup.
*
* Stacked rather than grouped because the meaningful quantity is the roster
* total and how it splits — grouped bars would make you add three numbers to
* answer "how many were rostered on Friday".
*/
export function AttendanceChart({
resource,
}: {
resource: Resource<AttendancePoint[]>;
}) {
return (
<ChartCard
title="Attendance"
subtitle="Daily roster split — present, late, absent"
resource={resource}
>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
isStacked
// Encoding is forced solid: the colour already separates these three,
// so the default hatch on the third series would be noise on top of
// an already-unambiguous signal.
series={[
{key: 'present', label: 'Present', encoding: 'solid'},
{
key: 'late',
label: 'Late',
color: CHART.attention,
encoding: 'solid',
},
{
key: 'absent',
label: 'Absent',
color: CHART.negative,
encoding: 'solid',
},
]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/useResource';
interface Row extends Record<string, unknown> {
id: string;
rank: number;
name: string;
role: string;
storeId: string;
attendance: Attendance;
salesCount: number;
rewardsIssued: number;
performanceScore: number;
punctualityPct: number;
}
/**
* The whole team, ranked.
*
* A table because this is a one-dimensional comparison across many rows —
* cards would force the eye to travel in two dimensions to answer "who is
* third". Capped at the top 10: a leaderboard that lists everyone is a
* roster, and the roster already exists above.
*/
export function Leaderboard({
resource,
}: {
resource: Resource<StaffMember[]>;
}) {
// Resolved from the workspace store directory, not from a fixture — see
// useStoreDirectory.
const storeName = useStoreName();
const columns: TableColumn<Row>[] = [
{
key: 'rank',
header: '#',
width: pixel(48),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.rank}
</Text>
),
},
{
key: 'name',
header: 'Team member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.role} · {storeName(row.storeId)}
</Text>
</VStack>
</HStack>
),
},
{
key: 'attendance',
header: 'Today',
width: pixel(112),
renderCell: (row) => (
<Badge
variant={ATTENDANCE[row.attendance].badge}
label={ATTENDANCE[row.attendance].label}
/>
),
},
{
key: 'salesCount',
header: 'Sales',
width: pixel(88),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatCompact(row.salesCount)}</Text>
),
},
{
key: 'rewardsIssued',
header: 'Rewards',
width: pixel(96),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatCompact(row.rewardsIssued)}</Text>
),
},
{
key: 'punctualityPct',
header: 'Punctuality',
width: pixel(112),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatPct(row.punctualityPct, 0)}</Text>
),
},
{
key: 'performanceScore',
header: 'Score',
// 140px was too narrow for a bar plus a 2-digit number side by side —
// the value wrapped under the bar and read as two separate figures.
width: pixel(180),
renderCell: (row) => (
<HStack gap={2} vAlign="center" wrap="nowrap">
<ProgressBar
value={row.performanceScore}
max={100}
label={`Score ${row.performanceScore} of 100`}
isLabelHidden
hasValueLabel={false}
/>
<Text size="sm" weight="medium">
{row.performanceScore}
</Text>
</HStack>
),
},
];
return (
<PanelCard
title="Leaderboard"
subtitle="Top 10 by performance score across the selected scope"
resource={resource}
loading={<SkeletonRows count={6} height={40} />}
empty={
<EmptyPanel
icon="leaderboard"
title="No one to rank yet"
description="The leaderboard fills in once staff record sales."
/>
}
>
{(staff) => {
const rows: Row[] = [...staff]
.sort((a, b) => b.performanceScore - a.performanceScore)
.slice(0, 10)
.map((m, i) => ({
id: m.id,
rank: i + 1,
name: m.name,
role: m.role,
storeId: m.storeId,
attendance: m.attendance,
salesCount: m.salesCount,
rewardsIssued: m.rewardsIssued,
performanceScore: m.performanceScore,
punctualityPct: m.punctualityPct,
}));
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="compact"
/>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Avatar} from '@astryxdesign/core/Avatar';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Divider} from '@astryxdesign/core/Divider';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {StaffMember} from '@/features/staff/types/staff';
/**
* One team member.
*
* The performance score gets a bar rather than a bare number because a score
* out of 100 is meaningless without a sense of the scale — 68 reads very
* differently as a filled bar than as a digit. Sales and rewards stay as
* plain figures; they are counts, not proportions.
*/
export function StaffCard({member}: {member: StaffMember}) {
const attendance = ATTENDANCE[member.attendance];
// Resolved from the workspace store directory, not from a fixture — see
// useStoreDirectory.
const storeName = useStoreName();
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={member.name}
subtitle={
<Text size="sm" color="secondary">
{member.role} · {storeName(member.storeId)}
</Text>
}
actions={
<Badge variant={attendance.badge} label={attendance.label} />
}
/>
<HStack gap={3} vAlign="center">
<Avatar name={member.name} size="md" tooltip={false} />
<VStack gap={1} width="100%">
<ProgressBar
value={member.performanceScore}
max={100}
label={`Performance score ${member.performanceScore} of 100`}
hasValueLabel={false}
/>
<HStack hAlign="between">
<Text size="xsm" color="secondary">
Performance
</Text>
<Text size="xsm" weight="medium">
{member.performanceScore}/100
</Text>
</HStack>
</VStack>
</HStack>
<Divider />
<StatRow>
<StatPair label="Sales" value={formatCompact(member.salesCount)} />
<StatPair
label="Rewards issued"
value={formatCompact(member.rewardsIssued)}
/>
<StatPair
label="Punctuality"
value={formatPct(member.punctualityPct, 0)}
align="end"
/>
</StatRow>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {FilterBar} from '@/shared/components/patterns/FilterBar';
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {StaffCard} from './StaffCard';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {
countByAttendance,
selectStaff,
} from '@/features/staff/services/staffService';
import type {StaffFilter} from '@/features/staff/services/staffService';
import type {StaffMember} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/useResource';
const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const;
export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<StaffFilter>('all');
// `?? []` allocates a fresh array every render, which would make it an
// unstable dependency and recompute both memos on every keystroke.
const staff = useMemo(() => resource.data ?? [], [resource.data]);
// Both derivations live in staffService: what counts as a match and what
// order the roster is in are product rules, and a redesign of this grid must
// not be able to change them by accident. The memos stay here because
// memoisation is a React concern, not a domain one.
const counts = useMemo(() => countByAttendance(staff), [staff]);
const visible = useMemo(
() => selectStaff(staff, {filter, search: query}),
[staff, query, filter],
);
return (
<CollectionRegion label="Team roster">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search name or role…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as StaffFilter)}
filterLabel="Filter staff by attendance"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'present', label: ATTENDANCE.present.label, hint: counts.present},
{value: 'late', label: ATTENDANCE.late.label, hint: counts.late},
{value: 'absent', label: ATTENDANCE.absent.label, hint: counts.absent},
{value: 'leave', label: ATTENDANCE.leave.label, hint: counts.leave},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'person' : 'people'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={230} minWidth={300} />}
empty={
<EmptyPanel
icon="staff"
title="No staff yet"
description="Team members appear here once they are added to a store."
/>
}
>
{() =>
visible.length === 0 ? (
<EmptyPanel
icon="staff"
title="Nobody matches"
description="Try a different attendance filter or clear the search."
/>
) : (
<Grid columns={STAFF_GRID_COLUMNS} gap={4}>
{visible.map((m) => (
<StaffCard key={m.id} member={m} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {formatPct} from '@/shared/utils/format';
import type {StaffSummary} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/useResource';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Attendance counts.
*
* Deliberately NOT MetricCard: these have no trend line and no
* period-over-period delta — "present today" has nothing meaningful to
* compare against last month. What they carry instead is a status dot and a
* share-of-total, so they get their own small card rather than a MetricCard
* with two empty slots.
*/
function CountCard({
label,
value,
total,
tone,
}: {
label: string;
value: number;
total: number;
tone: StatusDotVariant;
}) {
const share = total === 0 ? 0 : (value / total) * 100;
return (
<HoverLift>
{/* Padding and gaps match MetricCard so this row is the same height as
the dashboard KPI row (~112px). */}
<Card padding={4}>
<VStack gap={1}>
<HStack gap={2} vAlign="center">
<StatusDot variant={tone} label={label} />
<Text size="sm" color="secondary">
{label}
</Text>
</HStack>
{/* Text, not Heading — a count is data, not a section title. */}
<Text type="display-3">
<AnimatedNumber
value={value}
format={(v) => String(Math.round(v))}
/>
</Text>
<Text size="xsm" color="secondary">
{formatPct(share, 0)} of {total} rostered
</Text>
</VStack>
</Card>
</HoverLift>
);
}
export function StaffKpis({resource}: {resource: Resource<StaffSummary>}) {
const columns = useMetricColumns();
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(s) => (
<Grid columns={columns} gap={4}>
<CountCard
label={ATTENDANCE.present.label}
value={s.present}
total={s.total}
tone={ATTENDANCE.present.tone}
/>
<CountCard
label={ATTENDANCE.late.label}
value={s.late}
total={s.total}
tone={ATTENDANCE.late.tone}
/>
<CountCard
label={ATTENDANCE.absent.label}
value={s.absent}
total={s.total}
tone={ATTENDANCE.absent.tone}
/>
<CountCard
label="Total staff"
value={s.total}
total={s.total}
tone="neutral"
/>
</Grid>
)}
</AsyncBoundary>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import {useScope} from '@/shared/hooks/useScope';
import {useSampleResource} from '@/shared/mocks/withSample';
import {
buildAttendance,
buildStaff,
buildStaffSummary,
} from '@/shared/mocks/sampleData';
/**
* Shop-floor staff data — SAMPLE.
*
* The platform's /api/team is who can sign in, not who worked a shift, so
* attendance, sales-per-head and the leaderboard read
* shared/mocks/sampleData.ts locally. The Team table (features/team) is real.
*/
export function useStaffList() {
const {storeId, range} = useScope();
return useSampleResource(() => buildStaff(storeId, range), [storeId, range]);
}
export function useStaffSummary() {
const {storeId, range} = useScope();
return useSampleResource(() => buildStaffSummary(storeId, range), [storeId, range]);
}
export function useStaffAttendance() {
const {storeId, range} = useScope();
return useSampleResource((now) => buildAttendance(storeId, range, now), [storeId, range]);
}

View File

@@ -0,0 +1,60 @@
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
/**
* Domain rules for the staff module.
*
* These were inline in StaffGrid's useMemo. They are rules, not rendering:
* "the roster is ordered by who is carrying the store" is a product decision
* that a redesign of the grid must not be able to change by accident, and a
* second surface (the leaderboard, an export, a mobile view) has to be able to
* reuse it without copying a filter chain out of a component.
*
* Framework-free on purpose — no React, no Astryx — so it is unit-testable and
* survives any change to how staff are displayed.
*/
export type StaffFilter = Attendance | 'all';
export interface StaffQuery {
filter: StaffFilter;
/** Free text, matched against name and role. */
search: string;
}
/**
* Filter, then rank.
*
* Best performers first: the roster's default question is "who is carrying
* this store", not "who was added first". Ordering by score rather than name
* is the single decision that makes the grid answer it without a sort control.
*/
export function selectStaff(
staff: StaffMember[],
{filter, search}: StaffQuery,
): StaffMember[] {
const q = search.trim().toLowerCase();
return staff
.filter(
(m) =>
(filter === 'all' || m.attendance === filter) &&
(q === '' ||
m.name.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q)),
)
.sort((a, b) => b.performanceScore - a.performanceScore);
}
/** How many people are in each attendance state. Drives the filter counts. */
export function countByAttendance(
staff: StaffMember[],
): Record<StaffFilter, number> {
const counts: Record<StaffFilter, number> = {
all: staff.length,
present: 0,
late: 0,
absent: 0,
leave: 0,
};
for (const member of staff) counts[member.attendance] += 1;
return counts;
}

View File

@@ -0,0 +1,82 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
import type {Store, StoreStatus} from '@/features/stores/types/store';
/**
* Status is the one thing on this card allowed colour, because it is the one
* thing that can require action. A closed or under-maintenance store is why a
* merchant opened this page; its visitor count is context.
*/
const STATUS: Record<
StoreStatus,
{label: string; variant: 'success' | 'warning' | 'error' | 'neutral'}
> = {
open: {label: 'Open', variant: 'success'},
maintenance: {label: 'Maintenance', variant: 'warning'},
closed: {label: 'Closed', variant: 'error'},
};
export function StoreCard({store}: {store: Store}) {
const status = STATUS[store.status];
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={store.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.staff} size="xsm" color="secondary" />
<Text size="sm" color="secondary">
{store.staffCount} staff
</Text>
</HStack>
}
actions={<Badge variant={status.variant} label={status.label} />}
/>
<Divider />
<StatRow>
<StatPair label="Visitors" value={formatCompact(store.visitors)} />
<StatPair
label="Purchases"
value={formatCompact(store.purchases)}
/>
</StatRow>
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(store.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(store.conversionPct)}
/>
</StatRow>
<Button
variant="secondary"
size="sm"
label="Open dashboard"
href={`/stores/${store.id}`}
/>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {FilterBar} from '@/shared/components/patterns/FilterBar';
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {StoreCard} from './StoreCard';
import type {Store, StoreStatus} from '@/features/stores/types/store';
import type {Resource} from '@/shared/hooks/useResource';
type Filter = 'all' | StoreStatus;
/**
* Three-up on desktop, per spec.
*
* minWidth 270 is what actually yields three tracks once the 16px gaps are
* subtracted from a ~880px workspace — 300 silently collapsed it to two.
* Capped at 3 so a wide monitor doesn't stretch the roster into a thin strip,
* and it steps down to two when the Loyaly AI panel is taking 380px.
*/
const STORE_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
export function StoreGrid({resource}: {resource: Resource<Store[]>}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// Memoised on resource.data: `?? []` allocates a fresh array every render,
// which would make it an unstable dependency and recompute both memos below
// on every keystroke.
const stores = useMemo(() => resource.data ?? [], [resource.data]);
// Counts come from the unfiltered set so the chips still read "Closed (1)"
// while the closed filter is active — otherwise every chip but the selected
// one shows zero, which is useless.
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: stores.length,
open: 0,
maintenance: 0,
closed: 0,
};
for (const s of stores) c[s.status] += 1;
return c;
}, [stores]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return stores.filter(
(s) =>
(filter === 'all' || s.status === filter) &&
(q === '' || s.name.toLowerCase().includes(q)),
);
}, [stores, query, filter]);
return (
<CollectionRegion label="All stores">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search stores…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter stores by status"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'open', label: 'Open', hint: counts.open},
{value: 'maintenance', label: 'Maintenance', hint: counts.maintenance},
{value: 'closed', label: 'Closed', hint: counts.closed},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'store' : 'stores'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={240} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Stores appear here once they are added to your Loyaly account."
/>
}
>
{() =>
visible.length === 0 ? (
// A filter returning nothing is a different situation from having
// no stores at all, and needs a different way out.
<EmptyPanel
icon="stores"
title="No stores match"
description="Try a different status filter or clear the search."
/>
) : (
<Grid columns={STORE_GRID_COLUMNS} gap={4}>
{visible.map((s) => (
<StoreCard key={s.id} store={s} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}

View File

@@ -1,28 +1,24 @@
'use client';
import {storeRepository} from '@/features/stores/repositories/storeRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
import {useSampleResource} from '@/shared/mocks/withSample';
import {buildStore, buildStores} from '@/shared/mocks/sampleData';
/**
* Store data access. See features/dashboard/hooks/useDashboard for the pattern.
* Store performance — SAMPLE.
*
* The platform's real estate (shops, cameras) comes from useSites(); it
* reports no per-store revenue, staffing or trend, so the performance grid
* and the store detail page read shared/mocks/sampleData.ts locally.
*/
/**
* The directory always asks for ALL stores regardless of the workspace filter:
* a page whose job is to compare locations cannot be filtered down to one, or
* it has nothing to compare. The period still applies.
*/
export function useStoreList() {
const {range} = useScope();
return useResource(storeRepository.list({range, storeId: 'all'}));
return useSampleResource((now) => buildStores(range, now), [range]);
}
/** Null data when the id is not a sample store — the page renders not-found. */
export function useStore(storeId: string) {
const {range} = useScope();
return useResource(storeRepository.byId({range, storeId}, storeId), {
// A single object is never "empty" — without this, useResource's default
// array check would call a populated store empty.
isEmpty: () => false,
});
return useSampleResource((now) => buildStore(storeId, range, now), [storeId, range]);
}

View File

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