first commit
This commit is contained in:
85
src/features/lyts/ExpiryAlerts.tsx
Normal file
85
src/features/lyts/ExpiryAlerts.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {formatCompact, formatLyt} from '@/lib/format';
|
||||
import {daysUntil} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/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>
|
||||
);
|
||||
}
|
||||
90
src/features/lyts/RewardCard.tsx
Normal file
90
src/features/lyts/RewardCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/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 '@/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {formatCompact, formatLyt, formatPct} from '@/lib/format';
|
||||
import {REWARD_STATUS, daysUntil, usageRate} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* 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="xsm" color="secondary" />
|
||||
<Text size="sm" color="secondary">
|
||||
{formatLyt(reward.costLyt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={<Badge variant={status.variant} label={status.label} />}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
max={100}
|
||||
label={`Redeemed ${formatPct(rate, 0)} of claims`}
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<HStack hAlign="between">
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatCompact(reward.used)} redeemed
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
105
src/features/lyts/RewardGrid.tsx
Normal file
105
src/features/lyts/RewardGrid.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
|
||||
import {RewardCard} from './RewardCard';
|
||||
import type {Reward, RewardStatus} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/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>
|
||||
);
|
||||
}
|
||||
143
src/features/lyts/RewardPerformanceTable.tsx
Normal file
143
src/features/lyts/RewardPerformanceTable.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
||||
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 '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {MetricDelta} from '@/components/primitives/MetricDelta';
|
||||
import {formatCompact, formatLyt} from '@/lib/format';
|
||||
import {REWARD_STATUS, usageRate} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/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="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={REWARD_STATUS[row.status].variant}
|
||||
label={REWARD_STATUS[row.status].label}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'costLyt',
|
||||
header: 'Cost',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatLyt(row.costLyt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'claimed',
|
||||
header: 'Claimed',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCompact(row.claimed)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'used',
|
||||
header: 'Redeemed',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCompact(row.used)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
header: 'Rate',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
// Measured against a 50% house benchmark, so the arrow says "better or
|
||||
// worse than a reward should do" rather than "up or down since when?".
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="end">
|
||||
<MetricDelta value={row.rate - 50} isRiseGood size="xsm" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outstanding',
|
||||
header: 'Outstanding',
|
||||
width: pixel(128),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{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 (
|
||||
<Table
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
hasHover
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
29
src/features/lyts/reward-status.ts
Normal file
29
src/features/lyts/reward-status.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {RewardStatus} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* 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'},
|
||||
};
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
export function usageRate(claimed: number, used: number): number {
|
||||
return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1));
|
||||
}
|
||||
Reference in New Issue
Block a user