231 lines
8.1 KiB
TypeScript
231 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import {VStack} from '@astryxdesign/core/Layout';
|
|
import {Grid} from '@astryxdesign/core/Grid';
|
|
import {Text} from '@astryxdesign/core/Text';
|
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
|
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
|
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
|
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
|
|
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
|
|
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
|
|
import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
|
|
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
|
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
|
import {
|
|
useLytsActivity,
|
|
useRedemptions,
|
|
useRewards,
|
|
} from '@/features/lyts/hooks/useLyts';
|
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
|
import {ICONS} from '@/shared/utils/icons';
|
|
import {usageRate} from '@/features/lyts/services/lytsService';
|
|
import {
|
|
formatCompact,
|
|
formatDayLabel,
|
|
formatLyt,
|
|
formatPct,
|
|
} from '@/shared/utils/format';
|
|
|
|
/**
|
|
* The LYT programme — activities and the rewards they pay out in.
|
|
*
|
|
* Ordered by urgency rather than by data type: what's expiring, then the
|
|
* headline numbers, then what customers can DO to earn, then the catalogue of
|
|
* what they spend on, then the analysis. A merchant opening this page is
|
|
* usually here because something needs extending or pausing.
|
|
*
|
|
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
|
|
* rupee liability — which is why they lead.
|
|
*
|
|
* ── The activity half ─────────────────────────────────────────────────────
|
|
* Activities sit here, above rewards, because they are the earning side of
|
|
* the same ledger: an activity issues LYTs, a reward spends them, and a
|
|
* merchant tuning the programme is trading one against the other. The
|
|
* dashboard shows six activities as a summary of engagement; this page is
|
|
* where all ten are managed, and it reads the SAME activity model — see
|
|
* ActivityProgramme.
|
|
*/
|
|
export default function LytsPage() {
|
|
const rewards = useRewards();
|
|
const redemptions = useRedemptions();
|
|
const activity = useLytsActivity();
|
|
|
|
const columns = useMetricColumns();
|
|
|
|
// "Now" comes from the SERVER's clock, carried on every response.
|
|
//
|
|
// Date.now() here would be impure during render, would disagree between the
|
|
// SSR and hydration passes, and would report the wrong countdown to anyone
|
|
// whose device clock is off — on a panel whose entire job is telling a
|
|
// merchant an offer expires in two days. Falls back to 0 only before the
|
|
// first response lands, when no expiry panel is rendered anyway.
|
|
const nowMs = rewards.meta
|
|
? new Date(rewards.meta.generatedAt).getTime()
|
|
: 0;
|
|
|
|
return (
|
|
<VStack gap={5}>
|
|
<PageHeader
|
|
title="Lyts"
|
|
description="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1."
|
|
controls={<ScopeControls />}
|
|
/>
|
|
|
|
<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>
|
|
|
|
{/*
|
|
The earning side, before the spending side. All ten activities, their
|
|
status and their LYT cost — the same model the dashboard summarises.
|
|
*/}
|
|
<ActivityProgramme />
|
|
|
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
|
<ChartCard
|
|
title="Redemption"
|
|
subtitle="LYTs redeemed per day"
|
|
resource={redemptions}
|
|
>
|
|
{(d) => (
|
|
<AreaChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={formatCompact}
|
|
series={[{key: 'purchases', label: 'Redeemed'}]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
|
|
<ChartCard
|
|
title="Issued vs redeemed"
|
|
subtitle="The gap is LYTs still in customers' hands"
|
|
resource={redemptions}
|
|
>
|
|
{(d) => (
|
|
<LineChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={formatCompact}
|
|
series={[
|
|
{key: 'visitors', label: 'Issued'},
|
|
{key: 'purchases', label: 'Redeemed'},
|
|
]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
</Grid>
|
|
|
|
<ChartCard
|
|
title="Top rewards"
|
|
subtitle="Claimed vs redeemed, by reward"
|
|
resource={rewards}
|
|
>
|
|
{(rows) => (
|
|
<BarChartView
|
|
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
|
|
xKey="name"
|
|
yFormat={formatCompact}
|
|
series={[
|
|
{key: 'claimed', label: 'Claimed'},
|
|
{key: 'used', label: 'Redeemed'},
|
|
]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
|
|
<RewardGrid resource={rewards} nowMs={nowMs} />
|
|
|
|
<RewardPerformanceTable resource={rewards} />
|
|
|
|
<ActivityTimeline resource={activity} limit={6} height={285} />
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
);
|
|
}
|