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,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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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));
}