first commit

This commit is contained in:
2026-08-05 15:26:50 +05:30
parent c000e5dc95
commit 2c8c394795
126 changed files with 10824 additions and 302 deletions

View File

@@ -0,0 +1,213 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/components/primitives/PageHeader';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ICONS} from '@/lib/icons';
import {usageRate} from '@/features/lyts/reward-status';
import {
formatCompact,
formatDayLabel,
formatLyt,
formatPct,
} from '@/lib/format';
/**
* The LYT programme.
*
* Ordered by urgency rather than by data type: what's expiring, then the
* headline numbers, then the catalogue, then the analysis. A merchant opening
* this page is usually here because something needs extending or pausing.
*
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
* rupee liability — which is why they lead.
*/
export default function LytsPage() {
const {storeId, range} = useWorkspace();
const scope = {range, storeId};
const rewards = useResource(endpoints.lytsRewards(scope));
const redemptions = useResource(endpoints.lytsRedemptions(scope));
const activity = useResource(endpoints.lytsActivity(scope));
const columns = useMetricColumns();
// "Now" comes from the SERVER's clock, carried on every response.
//
// Date.now() here would be impure during render, would disagree between the
// SSR and hydration passes, and would report the wrong countdown to anyone
// whose device clock is off — on a panel whose entire job is telling a
// merchant an offer expires in two days. Falls back to 0 only before the
// first response lands, when no expiry panel is rendered anyway.
const nowMs = rewards.meta
? new Date(rewards.meta.generatedAt).getTime()
: 0;
return (
<VStack gap={5}>
<PageHeader
title="Lyts"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
/>
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
<AsyncBoundary
resource={rewards}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(rows) => {
const active = rows.filter(
(r) => r.status === 'active' || r.status === 'expiring',
);
const claimed = rows.reduce((a, r) => a + r.claimed, 0);
const used = rows.reduce((a, r) => a + r.used, 0);
const outstanding = rows.reduce(
(a, r) => a + (r.claimed - r.used) * r.costLyt,
0,
);
const mostClaimed = rows.reduce((a, b) =>
b.claimed > a.claimed ? b : a,
);
const leastUsed = rows.reduce((a, b) =>
usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used) ? b : a,
);
return (
<Grid columns={columns} gap={4}>
<MetricCard
label="Active rewards"
value={active.length}
format={(v) => String(Math.round(v))}
icon={ICONS.activeRewards}
footer={
<Caption text={`${rows.length} in catalogue`} />
}
/>
<MetricCard
label="Redemption rate"
value={usageRate(claimed, used)}
format={(v) => formatPct(v, 0)}
icon={ICONS.lyts}
deltaPct={usageRate(claimed, used) - 50}
footer={
<Caption
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
/>
}
/>
<MetricCard
label="Most claimed"
value={mostClaimed.claimed}
format={formatCompact}
icon={ICONS.up}
footer={<Caption text={mostClaimed.name} />}
/>
<MetricCard
label="Outstanding liability"
value={outstanding}
format={formatLyt}
icon={ICONS.alert}
footer={
<Caption
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
/>
}
/>
</Grid>
);
}}
</AsyncBoundary>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Redemption"
subtitle="LYTs redeemed per day"
resource={redemptions}
>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'purchases', label: 'Redeemed'}]}
/>
)}
</ChartCard>
<ChartCard
title="Issued vs redeemed"
subtitle="The gap is LYTs still in customers' hands"
resource={redemptions}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Issued'},
{key: 'purchases', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
</Grid>
<ChartCard
title="Top rewards"
subtitle="Claimed vs redeemed, by reward"
resource={rewards}
>
{(rows) => (
<BarChartView
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
<RewardGrid resource={rewards} nowMs={nowMs} />
<RewardPerformanceTable resource={rewards} />
<ActivityTimeline resource={activity} />
</VStack>
);
}
/**
* Stands in for the sparkline on metrics with no meaningful time series —
* "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
* for exactly this so the card keeps its height and the row stays aligned.
*/
function Caption({text}: {text: string}) {
return (
<Text size="xsm" color="secondary">
{text}
</Text>
);
}