update ui update and fix layout issue
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
|
||||
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';
|
||||
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.
|
||||
@@ -3,17 +3,18 @@
|
||||
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 {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 '@/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';
|
||||
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.
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
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 {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 '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import type {Reward, RewardStatus} from '@/features/lyts/types/reward';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
type Filter = 'all' | RewardStatus;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
|
||||
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 '@/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';
|
||||
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;
|
||||
19
src/features/lyts/hooks/useLyts.ts
Normal file
19
src/features/lyts/hooks/useLyts.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import {lytsRepository} from '@/features/lyts/repositories/lytsRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
/** LYT data access. See features/dashboard/hooks/useDashboard for the pattern. */
|
||||
|
||||
export function useRewards() {
|
||||
return useResource(lytsRepository.rewards(useScope()));
|
||||
}
|
||||
|
||||
export function useRedemptions() {
|
||||
return useResource(lytsRepository.redemptions(useScope()));
|
||||
}
|
||||
|
||||
export function useLytsActivity() {
|
||||
return useResource(lytsRepository.activity(useScope()));
|
||||
}
|
||||
129
src/features/lyts/mock/lyts.mock.ts
Normal file
129
src/features/lyts/mock/lyts.mock.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type {ActivityEvent, TimePoint} from '@/features/dashboard/types/dashboard';
|
||||
import type {Reward, RewardStatus} from '@/features/lyts/types/reward';
|
||||
import type {RangeKey} from '@/shared/types/api';
|
||||
import {createRng} from '@/shared/mock/rng';
|
||||
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
|
||||
|
||||
/**
|
||||
* The reward catalogue.
|
||||
*
|
||||
* `usageRate` is fixed per reward rather than random, because the story this
|
||||
* module has to tell is that redemption rates differ WILDLY by offer type —
|
||||
* Buy-1-Get-1 is claimed constantly and redeemed rarely, a free coffee is
|
||||
* claimed and used almost immediately. A catalogue where everything converts
|
||||
* at ~50% would make "most claimed" and "least used" the same reward and the
|
||||
* whole module pointless.
|
||||
*
|
||||
* 1 LYT = ₹1, so costLyt doubles as the rupee liability per redemption.
|
||||
*/
|
||||
const CATALOGUE: {
|
||||
id: string;
|
||||
name: string;
|
||||
costLyt: number;
|
||||
usageRate: number;
|
||||
status: RewardStatus;
|
||||
/** Days from now; null never expires. */
|
||||
expiresInDays: number | null;
|
||||
}[] = [
|
||||
{id: 'r-coffee', name: 'Free Coffee', costLyt: 120, usageRate: 0.72, status: 'active', expiresInDays: 34},
|
||||
{id: 'r-combo20', name: 'Combo 20% off', costLyt: 200, usageRate: 0.34, status: 'expiring', expiresInDays: 2},
|
||||
{id: 'r-weekend', name: 'Weekend Bonus', costLyt: 150, usageRate: 0.81, status: 'expiring', expiresInDays: 4},
|
||||
{id: 'r-birthday', name: 'Birthday Treat', costLyt: 250, usageRate: 0.55, status: 'active', expiresInDays: null},
|
||||
{id: 'r-bogo', name: 'Buy 1 Get 1', costLyt: 400, usageRate: 0.19, status: 'active', expiresInDays: 61},
|
||||
{id: 'r-dessert', name: 'Dessert on us', costLyt: 90, usageRate: 0.44, status: 'paused', expiresInDays: 20},
|
||||
{id: 'r-firstvisit', name: 'First Visit 10%', costLyt: 100, usageRate: 0.63, status: 'active', expiresInDays: 90},
|
||||
{id: 'r-loyalty500', name: 'Loyalty 500', costLyt: 500, usageRate: 0.28, status: 'expired', expiresInDays: -6},
|
||||
];
|
||||
|
||||
export function buildRewards(
|
||||
storeId: string,
|
||||
range: RangeKey,
|
||||
endMs: number,
|
||||
): Reward[] {
|
||||
const scoped = storeId === 'all' ? 1 : 0.28;
|
||||
|
||||
return CATALOGUE.map((r) => {
|
||||
const rng = createRng('reward', r.id, storeId, range);
|
||||
const claimed = Math.round(rng.int(140, 900) * scoped);
|
||||
const used = Math.round(claimed * r.usageRate * rng.float(0.92, 1.08));
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costLyt: r.costLyt,
|
||||
claimed,
|
||||
used: Math.min(used, claimed),
|
||||
expiresAt:
|
||||
r.expiresInDays === null
|
||||
? null
|
||||
: new Date(endMs + r.expiresInDays * 86400000).toISOString(),
|
||||
status: r.status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redemptions over time.
|
||||
*
|
||||
* Derived from the shared timeseries so redemption volume tracks footfall —
|
||||
* an independent generator would produce a redemption curve that contradicts
|
||||
* the store's own traffic, which is immediately obvious to anyone who knows
|
||||
* their business.
|
||||
*/
|
||||
export function buildRedemptions(
|
||||
storeId: string,
|
||||
range: RangeKey,
|
||||
endMs: number,
|
||||
): TimePoint[] {
|
||||
const points = buildTimeseries(range, storeId, endMs);
|
||||
const rng = createRng('redemptions', storeId, range);
|
||||
|
||||
return points.map((p) => {
|
||||
// Roughly a third of purchases involve a redemption.
|
||||
const redeemed = Math.round(p.purchases * rng.float(0.24, 0.38));
|
||||
const issued = Math.round(redeemed * rng.float(1.5, 2.4));
|
||||
return {
|
||||
t: p.t,
|
||||
// Reusing TimePoint's shape: visitors→issued, purchases→redeemed so the
|
||||
// existing chart primitives apply unchanged.
|
||||
visitors: issued,
|
||||
purchases: redeemed,
|
||||
revenue: redeemed * 140,
|
||||
conversion: issued ? Number(((redeemed / issued) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const LYT_ACTIVITY: {
|
||||
kind: ActivityEvent['kind'];
|
||||
title: string;
|
||||
detail: string;
|
||||
}[] = [
|
||||
{kind: 'reward_redeemed', title: 'Free Coffee redeemed', detail: '120 LYTs · Indiranagar Flagship'},
|
||||
{kind: 'reward_redeemed', title: 'Weekend Bonus redeemed', detail: '150 LYTs · Koramangala'},
|
||||
{kind: 'reward_expired', title: 'Loyalty 500 expired', detail: 'Claimed 310 times · used 87'},
|
||||
{kind: 'reward_redeemed', title: 'Dessert on us redeemed', detail: '90 LYTs · Anna Nagar'},
|
||||
{kind: 'purchase', title: 'Combo 20% applied at checkout', detail: '₹640 basket · Jubilee Hills'},
|
||||
];
|
||||
|
||||
export function buildLytActivity(
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): ActivityEvent[] {
|
||||
const rng = createRng('lyt-activity', storeId);
|
||||
// Accumulated, not redrawn per index — same ordering bug as buildActivity.
|
||||
let minutesAgo = 0;
|
||||
|
||||
return Array.from({length: 24}, (_, i) => {
|
||||
const tpl = LYT_ACTIVITY[i % LYT_ACTIVITY.length];
|
||||
if (i > 0) minutesAgo += rng.int(8, 50);
|
||||
return {
|
||||
id: `l${i}`,
|
||||
at: new Date(endMs - minutesAgo * 60000).toISOString(),
|
||||
kind: tpl.kind,
|
||||
title: tpl.title,
|
||||
detail: tpl.detail,
|
||||
storeId,
|
||||
};
|
||||
});
|
||||
}
|
||||
16
src/features/lyts/repositories/lytsRepository.ts
Normal file
16
src/features/lyts/repositories/lytsRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Reward} from '@/features/lyts/types/reward';
|
||||
import type {ActivityEvent, TimePoint} from '@/features/dashboard/types/dashboard';
|
||||
|
||||
/** LYT reward endpoints. See dashboardRepository for the layer's contract. */
|
||||
export const lytsRepository = {
|
||||
rewards: (scope: Scope): Endpoint<Reward[]> =>
|
||||
scopedEndpoint('/api/lyts/rewards', scope),
|
||||
|
||||
redemptions: (scope: Scope): Endpoint<TimePoint[]> =>
|
||||
scopedEndpoint('/api/lyts/redemptions', scope),
|
||||
|
||||
activity: (scope: Scope): Endpoint<ActivityEvent[]> =>
|
||||
scopedEndpoint('/api/lyts/activity', scope),
|
||||
};
|
||||
53
src/features/lyts/services/lytsService.ts
Normal file
53
src/features/lyts/services/lytsService.ts
Normal 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);
|
||||
}
|
||||
19
src/features/lyts/types/reward.ts
Normal file
19
src/features/lyts/types/reward.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* LYT reward contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
export type RewardStatus = 'active' | 'paused' | 'expiring' | 'expired';
|
||||
|
||||
export interface Reward {
|
||||
id: string;
|
||||
name: string;
|
||||
costLyt: number;
|
||||
claimed: number;
|
||||
used: number;
|
||||
expiresAt: string | null;
|
||||
status: RewardStatus;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {RewardStatus} from '@/lib/api/contracts';
|
||||
import type {RewardStatus} from '@/features/lyts/types/reward';
|
||||
|
||||
/**
|
||||
* Reward status → badge treatment.
|
||||
@@ -18,12 +18,8 @@ export const REWARD_STATUS: Record<
|
||||
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));
|
||||
}
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
Reference in New Issue
Block a user